wandres.dev
MODOS DE RENDER · SSR, streaming, CSR, SSG

SSR síncrono vs en streaming

Renderizar en el servidor no es una sola cosa: es una familia de estrategias que difieren en cuándo y en cuántos trozos llega el HTML al navegador. Solid ofrece tres funciones de render de servidor —renderToString síncrono sin datos, renderToStringAsync que bloquea hasta resolver todo, y renderToStream que emite el shell primero y transmite cada boundary de Suspense según resuelve—. Esta lección diseca las tres, explica cómo el streaming out-of-order coloca los fragmentos en su hueco con scripts inline, por qué mejora FCP y TTFB percibido, y cuándo conviene bloquear a propósito con deferStream para no servir un hueco a un buscador.

⏱ 18 min

«Renderizar en el servidor» esconde una decisión que casi nadie nombra: cuándo sale el HTML y en cuántos trozos. Puedes esperar a tener el documento entero y mandarlo de una vez, o puedes mandar el esqueleto al instante e ir transmitiendo cada región según sus datos resuelven. Solid materializa esas dos filosofías en tres funciones de render —una síncrona sin datos, una asíncrona que bloquea, y una que transmite— y la costura entre todas es Suspense. Entender la diferencia es entender por qué dos apps con el mismo SSR pueden sentirse una instantánea y la otra pegajosa.

🎯 Al terminar esta lección sabrás
  • Distinguir renderToString, renderToStringAsync y renderToStream por cuándo emiten el HTML.
  • Ver cómo el streaming manda el shell primero y transmite cada Suspense al resolver.
  • Entender el streaming out-of-order: fragmentos que llegan desordenados y un script los coloca.
  • Saber cuándo bloquear a propósito con deferStream por SEO o contenido crítico.

Las tres funciones de render del servidor

Solid expone en solid-js/web tres formas de convertir tu árbol en HTML de servidor, y la elección gobierna el perfil de rendimiento entero.

import { renderToString, renderToStringAsync, renderToStream } from "solid-js/web";

// 1) Sincrona: rapidisima, pero NO espera datos asincronos.
const html = renderToString(() => <App />);

// 2) Asincrona: espera a que todo Suspense resuelva, luego emite de una vez.
const htmlCompleto = await renderToStringAsync(() => <App />);

// 3) Streaming: emite el shell ya y transmite cada boundary segun resuelve.
renderToStream(() => <App />).pipe(respuesta);

renderToString recorre el árbol de forma síncrona y no sabe esperar: cualquier recurso queda en su estado de carga, así que sirves el HTML con los fallback puestos y el cliente rellena tras hidratar. Es veloz en TTFB pero entrega una página sin datos —útil para contenido sin async, casi nunca para una vista con datos—.

renderToStringAsync sí espera: recorre el árbol, deja que cada Suspense resuelva sus recursos y solo entonces devuelve el documento completo en un único flush. El HTML llega íntegro, ideal para un rastreador que no ejecuta JS, pero el usuario mira una conexión abierta hasta que el recurso más lento termina: tu TTFB es el de tu peor consulta.

Hay un caso en que ese TTFB alto sí conviene pagarlo: cuando la respuesta se va a cachear entera. Si una página pública idéntica para todos se renderiza una vez con renderToStringAsync y se guarda en una caché o un CDN, el único que espera el render completo es el primer visitante; los demás reciben el HTML íntegro al instante. Bloquear tiene sentido cuando amortizas ese bloqueo entre muchas visitas —justo la lógica que, llevada al extremo, desemboca en el prerender de la próxima lección—.

renderToStream rompe esa dependencia. Emite de inmediato el shell —el HTML que no depende de datos, con los fallback de cada Suspense en su sitio— y mantiene la conexión abierta para ir empujando cada región cuando sus datos llegan. Es el modo por defecto de SolidStart, y por buenas razones.

El orden en que las listo no es casual: van de menos a más capaces y de más a menos triviales. La primera ignora lo asíncrono; la segunda lo espera entero; la tercera lo transmite. Cada salto añade capacidad a cambio de más maquinaria por debajo, y la tercera es la que SolidStart eligió como cimiento precisamente porque su comportamiento por defecto —shell rápido, datos progresivos— es el que mejor sirve al caso común: una página con datos que un humano espera ver cuanto antes.

Conviene fijar el vocabulario, porque estos nombres se confunden a diario. «SSR síncrono» suele usarse para el render bloqueante que espera todos los datos y emite de una vez —renderToStringAsync—, y «SSR en streaming» para el que emite por partes —renderToStream—. El renderToString puro, sin async, es un tercer animal que rara vez quieres para una página con datos. Cuando alguien diga «lo tenemos en SSR», la primera pregunta útil es cuál de los tres, porque su perfil de TTFB y de SEO no se parece en nada.

renderToString

Sincrona y sin async. TTFB minimo, pero sirve los fallback: los datos llegan tras hidratar en el cliente.

🧱

renderToStringAsync

Espera a todo Suspense y emite el documento completo. HTML integro para buscadores, TTFB atado al dato mas lento.

🌊

renderToStream

Shell primero, boundaries despues. Mejor FCP y TTFB percibido. El modo por defecto de SolidStart.

En SolidStart no invocas estas funciones a mano salvo que quieras: el entry-server monta un StartServer que transmite por defecto, y tú te limitas a colocar Suspense donde quieras cortar el flujo. Saber que por debajo hay tres funciones distintas importa igual, porque explica qué palancas mueves cuando ajustas el comportamiento —cuándo bloqueas con renderToStringAsync, cuándo difieres un recurso, cuándo un boundary decide un corte—. El modo por defecto es una decisión, no una fatalidad, y como toda decisión se puede revisar por ruta.

Streaming: el shell primero, los datos después

La clave del streaming es que Suspense deja de ser solo una frontera de carga del cliente y pasa a ser también un punto de corte del flujo. Cuando el render de servidor topa con un Suspense cuyo recurso aún no resolvió, no se bloquea: escribe el fallback en el stream, anota el hueco y sigue con el resto del documento. Así el navegador recibe una página pintable en milisegundos.

sequenceDiagram
participant N as Navegador
participant S as Servidor Solid
N->>S: GET de la ruta
S-->>N: shell HTML con los fallback de Suspense
Note over N: FCP temprano pinta el esqueleto
S->>S: el recurso lento resuelve
S-->>N: fragmento del boundary mas un script inline
Note over N: el script mueve el fragmento a su hueco
S-->>N: cierra el stream cuando todo resolvio

Cuando un recurso resuelve, Solid serializa el HTML real de ese Suspense y lo empuja por la misma conexión seguido de un diminuto script inline. Ese script toma el fragmento —que el navegador pinta oculto al final del body— y lo reubica en el hueco que dejó el fallback. Por eso se llama streaming out-of-order: los boundaries pueden llegar en cualquier orden, el más rápido primero, y cada uno trae consigo las instrucciones para colocarse. El usuario ve el contenido aparecer región a región, sin esperar al conjunto.

Solid permite además elegir entre streaming out-of-order y en orden. El primero, por defecto, emite cada boundary en cuanto resuelve y lo recoloca con un script —máxima velocidad, a costa de unos pequeños reordenamientos—. El segundo respeta el orden del documento, reteniendo un fragmento ya resuelto hasta que salgan los anteriores —HTML que se lee en secuencia incluso sin ejecutar JavaScript, útil si te importa el resultado para clientes que no corren scripts—. Es otra perilla del mismo dial: cuánta progresividad cambias por cuánta linealidad.

La hidratación se acopla a este flujo sin fricción. Solid serializa junto al HTML el estado de los recursos ya resueltos, de modo que cuando el cliente hidrata no vuelve a pedir esos datos: los recoge del propio documento. El streaming, por tanto, no es un truco visual pegado por fuera; es la misma máquina de datos del framework proyectada sobre una conexión que se va llenando por partes.

¿Y si un recurso falla en mitad del stream? La simetría se mantiene: igual que Suspense transmite su fallback y luego su contenido, un ErrorBoundary transmite su estado de error cuando la promesa de su interior rechaza. Como el shell ya salió, el fallo no rompe la página entera; solo la región afectada intercambia su contenido por el fallback de error, empujado por el mismo mecanismo de fragmento más script. El streaming no obliga a elegir entre progresividad y robustez: hereda las dos fronteras que ya conoces, la de carga y la de fallo, y las proyecta ambas sobre el flujo.

ℹ️
El mismo Suspense que ya escribiste

No hay una API nueva de streaming que aprender. Los mismos Suspense que colocaste para la carga en el cliente son los que el servidor usa como puntos de corte del flujo. Escribes la app pensando en fronteras de carga y obtienes streaming de servidor gratis: la arquitectura de datos y la de render son la misma pieza vista desde dos entornos.

Las ventajas se acumulan. El TTFB se desacopla de tus datos porque el shell no depende de ellos. El FCP y el LCP mejoran porque el navegador pinta y empieza a descargar recursos mientras el servidor aún trabaja. Y desaparece la cascada clásica del SSR bloqueante —servidor espera datos, luego navegador espera HTML, luego hidrata—: aquí las fases se solapan.

Fronteras anidadas: cortes dentro de cortes

Suspense no es un interruptor único de página: es componible, y cada frontera que anidas crea un punto de corte independiente en el flujo. Una lista con su propio Suspense y un panel con el suyo transmiten por separado, cada uno en cuanto sus datos resuelven, sin esperarse mutuamente.

<Suspense fallback={<EsqueletoPagina />}>
  <Cabecera />
  <Suspense fallback={<EsqueletoLista />}>
    <ListaProductos /> {/* boundary interno: fluye aparte */}
  </Suspense>
  <Suspense fallback={<EsqueletoPanel />}>
    <PanelRecomendaciones /> {/* otro boundary: su propio corte */}
  </Suspense>
</Suspense>

El grano de tus fronteras es, por tanto, el grano de tu streaming. Fronteras gruesas transmiten en bloques grandes —menos scripts de reubicación, pero cada región espera a la más lenta de su grupo—; fronteras finas transmiten pieza a pieza —máxima progresividad a cambio de más fragmentos—. Colocar Suspense deja de ser solo una decisión de carga y se vuelve una decisión de cómo se rasga tu HTML en el tiempo. Ese es el poder oculto del streaming de Solid: la misma primitiva que declara la experiencia de carga declara también la granularidad del flujo, sin una API aparte para lo segundo.

En la práctica, colocas Suspense alrededor de lo que quieres que aparezca junto y que puede tardar junto: una lista y su paginador en una frontera, un panel lateral en otra. El instinto correcto no es minimizar ni maximizar fronteras, sino agruparlas por afinidad de datos y de sentido visual —que es la misma agrupación que ya harías pensando solo en la carga—. El streaming, una vez más, no te pide diseñar dos veces.

Hay un límite físico que conviene tener presente: cada fragmento transmitido viaja con su pizca de script de reubicación, así que fronteras absurdamente finas —un Suspense por celda de una tabla— multiplican ese pequeño coste sin ganancia perceptible. Como casi todo en este nivel, el grano óptimo no está en el extremo, sino en el punto que sigue la estructura natural de tus datos y de lo que el usuario percibe como una unidad.

Cuándo bloquear a propósito: deferStream

El streaming tiene un matiz: como el shell sale antes que los datos, el primer HTML de un boundary contiene su fallback, no su contenido. Para contenido que debe estar en la respuesta inicial —lo que un buscador lee sin ejecutar scripts, o lo que ocupa el LCP por encima del pliegue— eso puede no bastar. La salida es pedir explícitamente que el stream espere a ese recurso antes de emitir el shell.

// El render en streaming NO enviara el shell hasta que este dato resuelva.
const articulo = createAsync(() => cargarArticulo(params.slug), { deferStream: true });

// Lo mismo con createResource:
const [datos] = createResource(fuente, buscar, { deferStream: true });

deferStream: true le dice al renderizador que trate ese recurso como parte del shell: bloquea el primer flush hasta tenerlo, y a partir de ahí sigue transmitiendo el resto. Es una válvula quirúrgica —el contenido crítico llega en el HTML inicial sin renunciar al streaming del resto—. La disciplina consiste en marcar solo lo indispensable: abusar de deferStream reconvierte tu streaming en un renderToStringAsync encubierto, con su TTFB atado al recurso más lento.

Piensa en deferStream como la costura entre los dos mundos de esta lección: dentro de un render en streaming, marca los recursos que quieres tratar como si fueran de un render bloqueante. Es la prueba de que síncrono y streaming no son dos modos opuestos y estancos sino los extremos de un mismo dial que ajustas recurso a recurso —el título y el LCP bloqueando el shell, el resto del contenido fluyendo detrás—.

Una heurística práctica para decidir qué difieres: pregúntate si ese contenido debe estar en el HTML que ve un rastreador o un scraper de previsualización, y si ocupa el área visible antes de hacer scroll. Si ambas respuestas son sí, defiérelo para que viaje en el shell. Si es contenido por debajo del pliegue, secundario, o que solo importa a un humano que ya está mirando la página, déjalo fluir: retenerlo solo empeoraría el TTFB sin beneficio para nadie.

El streaming no acelera el servidor: solapa la espera con el trabajo del navegador

La intuición ingenua dice que el streaming «hace el SSR más rápido», y es una media verdad que conviene corregir. El servidor no resuelve tus datos ni un milisegundo antes por transmitir; la consulta lenta sigue tardando lo mismo. Lo que el streaming cambia no es la duración del trabajo, sino su solapamiento. En el SSR bloqueante las fases van en serie: el servidor reúne todos los datos, luego compone todo el HTML, luego el navegador lo recibe entero, lo parsea, descarga los recursos y recién entonces pinta; cada fase espera a que la anterior termine del todo, y el usuario mira una pantalla en blanco durante la suma de todas. El streaming convierte esa suma en un máximo parcial. En cuanto el shell sale —y sale casi al instante, porque no depende de datos— el navegador ya está parseando, descubriendo hojas de estilo y scripts, y pintando el esqueleto, todo mientras el servidor sigue esperando la consulta lenta. Las dos líneas de tiempo, la del servidor resolviendo datos y la del navegador construyendo la página, dejan de ir una tras otra y corren en paralelo. Por eso el streaming mejora las métricas que miden percepción —FCP, LCP, TTFB percibido— sin tocar la métrica que mide trabajo total. Y por eso la decisión entre renderToStringAsync y renderToStream no es «lento contra rápido», sino «documento completo de una vez contra experiencia progresiva»: eliges bloquear cuando quien consume necesita el HTML íntegro y no sabe esperar fragmentos —un rastreador tonto, una respuesta que se cachea entera—, y eliges transmitir cuando quien consume es un humano frente a un navegador que puede empezar a trabajar con lo primero que le des. Suspense, deferStream y la elección de función de render son las tres perillas con las que afinas dónde, en ese espectro entre completitud y progresividad, quieres estar por cada ruta.

⚔️ Mide las tres estrategias de render
  1. Monta una ruta con un createAsync que tarde a propósito dos segundos y sírvela con renderToStringAsync; observa que el TTFB es de dos segundos y el HTML llega completo.
  2. Cambia a renderToStream (el modo por defecto de SolidStart) y comprueba en la pestaña de red que el shell llega al instante y el fragmento del Suspense después.
  3. Inspecciona el HTML transmitido y localiza el script inline que reubica el fragmento en el hueco del fallback; explica qué hace el streaming out-of-order.
  4. Marca ese recurso con deferStream: true y razona cómo el TTFB vuelve a atarse al dato, y en qué caso —SEO, LCP— compensa pagarlo.
  5. Argumenta con métricas —TTFB, FCP, LCP— por qué el streaming solapa espera y trabajo en vez de acortar el trabajo del servidor.