wandres.dev
SUSPENSE · coordinar la carga

Suspense en SSR: streaming del HTML por partes

En el servidor, Suspense se convierte en la unidad de streaming. Con renderToStream Solid manda de inmediato el shell del HTML con un placeholder en el hueco de cada límite pendiente, y a medida que los recursos resuelven va flushando chunks: el HTML real de ese límite en una plantilla, un script inline que sustituye el placeholder y los datos del recurso serializados para que el cliente no vuelva a pedirlos. Se contrastan renderToString, renderToStringAsync y renderToStream, se sigue el ciclo shell-placeholder-chunk-swap del streaming fuera de orden, se ve cómo deferStream fuerza a esperar un recurso crítico antes de mandar el shell, y las opciones onCompleteShell y onCompleteAll del stream.

⏱ 18 min

En el cliente, Suspense decide qué se ve mientras se espera. En el servidor adquiere un segundo poder: se convierte en la unidad de streaming. En vez de retener toda la respuesta hasta tener cada dato, Solid manda ya el armazón de la página con un hueco marcado donde cada límite todavía carga, y luego va cosiendo esos huecos —enviando el HTML real de cada región en cuanto su recurso resuelve en el servidor, junto a un pequeño script que lo encaja en su sitio—. El usuario recibe algo pintable de inmediato y la página se completa por partes, fuera de orden. Esta lección explica las tres formas de renderizar en servidor y el mecanismo exacto del relleno.

🎯 Al terminar esta lección sabrás
  • Distinguir renderToString, renderToStringAsync y renderToStream.
  • Ver Suspense como la unidad de streaming: un hueco que se rellena por separado.
  • Seguir el ciclo shell, placeholder, chunk y script de sustitución.
  • Evitar el doble fetch en la hidratación y usar deferStream cuando convenga.

Tres formas de renderizar en el servidor

Solid ofrece tres funciones de render de servidor, y elegir bien es media batalla. renderToString es síncrona: no espera a ningún recurso, así que emite el fallback de cada Suspense y devuelve el HTML al instante —rápido, pero sin datos—. renderToStringAsync espera a que todos los recursos resuelvan y entonces devuelve el HTML completo de una vez: datos listos, pero bloqueas la respuesta hasta el recurso más lento. renderToStream es el punto óptimo: manda el shell ahora y va rellenando los huecos por streaming a medida que resuelven.

Función Espera recursos Salida
renderToString no HTML síncrono con los fallback
renderToStringAsync sí, todos HTML completo de una sola vez
renderToStream por límite shell ya y huecos en streaming
import { renderToStream } from "solid-js/web";

const { pipe } = renderToStream(() => <App />);
pipe(respuesta); // manda el shell de inmediato y va cosiendo los huecos al resolver

renderToStream da el mejor TTFB —el tiempo hasta el primer byte— porque no espera a nada para enviar el armazón, y aun así entrega datos reales conforme llegan. Es el modo que SolidStart usa por defecto para SSR, y el que conviene salvo que tengas una razón concreta para bloquear.

El hueco y su relleno: streaming fuera de orden

El mecanismo es más simple de lo que parece. Al renderizar en streaming, cada Suspense cuyo recurso aún no resolvió emite su fallback dentro de un elemento marcado con un identificador: ese es el hueco. El shell con todos esos huecos se envía de inmediato y el navegador lo pinta —el usuario ya ve la página con sus esqueletos—. Después, cada vez que el recurso de un límite resuelve en el servidor, Solid flusha un chunk que contiene dos cosas: el HTML real de esa región dentro de una plantilla oculta, y un pequeño script inline que reemplaza el nodo del placeholder por ese contenido. Todo ello acompañado de los datos del recurso serializados, para que el cliente no tenga que volver a pedirlos.

flowchart TD
SH[el servidor manda el shell con el placeholder] --> CLI[el navegador pinta el fallback ya]
RES[el recurso del limite resuelve en el servidor] --> CHK[flusha un chunk con el html real y los datos]
CHK --> SWP[un script inline sustituye el placeholder]
SWP --> HYD[la hidratacion lee los datos serializados sin refetch]
style SH fill:#89b4fa,color:#11111b
style SWP fill:#a6e3a1,color:#11111b

Lo decisivo es que este relleno es fuera de orden: los chunks se envían en el orden en que los recursos resuelven, no en el orden del documento. Un límite del pie de página cuyo dato llega antes se cose antes que uno de la cabecera que tarda más, y el script inline se encarga de colocar cada trozo en su hueco correcto sea cual sea el orden de llegada. Cada Suspense es, por tanto, una costura independiente: un punto del HTML que puede completarse por su cuenta sin bloquear a los demás.

Hidratación sin doble fetch y deferStream

El detalle que cierra el círculo son los datos serializados. Cuando el cliente hidrata la página, no vuelve a ejecutar los fetch de los recursos: los lee de la carga que Solid inyectó en el HTML junto a cada chunk. Sin ese puente, cada recurso se pediría dos veces —una en el servidor para el SSR y otra en el cliente al hidratar—, desperdiciando la petición del servidor. La serialización hace que el trabajo asíncrono del servidor se reutilice íntegro en el cliente, y la hidratación se limita a reconectar la reactividad sobre un HTML que ya trae sus valores.

import { createResource } from "solid-js";

// deferStream fuerza al stream a esperar este recurso antes de mandar el shell.
const [critico] = createResource(cargarCritico, { deferStream: true });

A veces no quieres que un dato viaje como hueco a rellenar sino que forme parte del shell inicial —contenido sobre el pliegue, algo crítico para SEO o para evitar un salto visible—. Para eso, createResource acepta la opción deferStream: true, que le dice al stream: «no mandes el armazón hasta que yo haya resuelto». Ese recurso deja de ser fuera-de-orden y bloquea el primer flush, entrando ya resuelto en el HTML inicial. Es la válvula para elegir, recurso a recurso, qué entra en el shell y qué se cose después.

En SolidStart no orquestas la serialización a mano: sus primitivas de datos ya la hacen. Un createAsync bajo un Suspense se transmite, se cose y se hidrata sin doble fetch, con la caché por petición del router de por medio:

import { createAsync } from "@solidjs/router";
import { Suspense } from "solid-js";

function Ficha(props: { id: string }) {
  const dato = createAsync(() => obtenerFicha(props.id)); // se serializa solo
  return (
    <Suspense fallback={<Esqueleto />}>
      <h1>{dato()?.titulo}</h1>
    </Suspense>
  );
}

Afinar el stream: onCompleteShell y onCompleteAll

renderToStream acepta un segundo argumento de opciones que da control fino sobre el flujo. onCompleteShell se dispara cuando el armazón síncrono está listo para enviarse —el punto en que arranca el streaming—, y onCompleteAll cuando todos los límites han resuelto y cosido su contenido, útil para cerrar la respuesta o registrar métricas. Un nonce propaga la política de seguridad de contenido a los scripts inline que insertan los chunks.

const { pipe } = renderToStream(() => <App />, {
  nonce: req.nonce,
  onCompleteShell() {
    respuesta.write("<!-- shell listo -->"); // el armazón ya viaja
  },
  onCompleteAll() {
    respuesta.end(); // todos los huecos cosidos; cierra el stream
  },
});
pipe(respuesta);
Opción de renderToStream Para qué sirve
nonce propaga la CSP a los scripts inline de los chunks
onCompleteShell se dispara al emitir el armazón síncrono
onCompleteAll se dispara cuando todos los límites cosieron su contenido
renderId prefija ids para varios renders en una misma página

Estas asas te dejan orquestar el ciclo completo sin adivinar tiempos: sabes exactamente cuándo salió el shell y cuándo terminó la última costura. En SolidStart rara vez las tocas a mano —el framework las gobierna— pero conocerlas revela que el streaming no es magia opaca: es un shell que se emite en onCompleteShell y una serie de chunks que terminan en onCompleteAll, con Suspense marcando cada punto donde el flujo se puede cortar.

TTFB inmediato

El shell se envía sin esperar a ningún dato, así que el navegador pinta algo enseguida.

🧵

Costura fuera de orden

Cada Suspense se rellena cuando su recurso resuelve, en cualquier orden, sin bloquear a los demás.

🔁

Sin doble fetch

Los datos serializados en el HTML alimentan la hidratación; el cliente no repite las peticiones.

🩹

deferStream

La válvula para forzar a un recurso crítico a entrar en el shell en vez de llegar cosido después.

📝
isServer para lo que solo tiene sentido en el cliente

Bajo streaming, el cuerpo de tus componentes corre en el servidor y luego en el cliente. Para la lógica que solo debe existir en el navegador —un WebSocket, localStorage, matchMedia— guárdala tras isServer de solid-js/web, de modo que el render de servidor la salte y no intente tocar APIs que allí no existen. Un recurso puede cargar datos en ambos lados; un efecto que abre un socket, no. isServer es la frontera que evita que el shell se rompa por código pensado para el cliente, sin afectar a cómo Suspense cose los huecos de datos.

💡
SolidStart usa renderToStream por defecto

En una app de SolidStart no llamas a estas funciones a mano: el framework renderiza en streaming por ti, y tú solo colocas los Suspense donde quieras huecos. Las primitivas de datos del framework —createAsync y el query del router— ya se integran con el mecanismo de serialización, así que la hidratación sin doble fetch la obtienes gratis. Saber qué hace renderToStream por debajo no es un lujo académico: es lo que te permite razonar sobre por qué una sección aparece antes que otra, dónde poner un límite para mejorar el TTFB, y cuándo un deferStream merece bloquear el shell por un contenido crítico.

⚠️
Un shell rehén de un dato lento anula el streaming

El error que vacía de sentido al streaming es abusar de deferStream o colocar un único Suspense tan alto que envuelva un recurso lento: entonces el shell espera a ese dato y el TTFB se desploma hasta parecerse al de renderToStringAsync. La regla es la inversa de la intuición del render bloqueante: deja fuera de los límites solo lo verdaderamente instantáneo y estático, y mete cada dato asíncrono en un Suspense para que viaje como hueco cosido, no como lastre del primer byte. Reserva deferStream para lo poco que de verdad no admite aparecer un instante después.

El Suspense es la costura entre lo que se envía ya y lo que se cose después

La revelación de la SSR en streaming es que Suspense deja de ser un componente de UI para volverse una frontera temporal en el propio HTML: la línea que separa lo que el servidor ya puede mandar de lo que todavía está resolviendo. Todo lo que queda fuera de un límite —el armazón, la navegación, el texto estático— es síncrono y viaja en el primer byte, dándole al usuario algo real de inmediato. Todo lo que queda dentro de un límite es una promesa de contenido: un hueco marcado que el servidor rellenará en cuanto el dato exista, cosiéndolo con un script inline y sembrando de paso los valores para que el cliente no repita el trabajo. Esta es la misma dualidad que ha recorrido el nivel entero, ahora proyectada sobre el tiempo de la red: el contador de lecturas que en el cliente decidía fallback-o-contenido, en el servidor decide shell-ahora-o-chunk-después. Y por eso las decisiones de dónde colocar tus Suspense dejan de ser cosméticas para volverse arquitectónicas: cada límite es una unidad de streaming, un punto donde eliges cortar el flujo entre lo inmediato y lo diferido. Un límite demasiado alto mete datos lentos en el shell y hunde el TTFB; uno demasiado bajo fragmenta la página en cien costuras que parpadean. Colocarlos bien es dibujar, sobre tu interfaz, el mapa de lo que el usuario merece ver ya y lo que puede llegar cosido un instante después —y deferStream es el bisturí para mover una costura concreta al lado del shell cuando ese contenido no admite espera—. Dominar Suspense en el servidor es dominar el reparto del tiempo entre el primer byte y el último.

⚔️ Cose una página por partes
  1. Renderiza una app con renderToStream y pipe, y confirma en la red que el shell llega antes de que los recursos resuelvan, con los fallback visibles al instante.
  2. Sustituye por renderToStringAsync y contrasta: el HTML llega completo pero el TTFB se retrasa hasta el recurso más lento.
  3. Coloca dos Suspense con latencias distintas y observa que sus chunks llegan fuera de orden —primero el rápido— y que cada script inline coloca su trozo en el hueco correcto.
  4. Marca un recurso sobre el pliegue con deferStream: true y comprueba que ahora entra ya resuelto en el shell inicial en lugar de llegar como hueco cosido después.
  5. Registra onCompleteShell y onCompleteAll y usa sus tiempos para medir cuánto separó el streaming al primer byte del último.