Streaming de HTML: enviar la página antes de tenerla entera
Qué desbloquea exactamente enviar la cabecera pronto, cómo se hace con y sin framework, el truco del contenido fuera de secuencia, y qué anula el streaming sin avisar.
HTTP permite enviar una respuesta en trozos desde 1997 y el navegador lleva desde entonces renderizando HTML de forma incremental, sin esperar a la etiqueta de cierre. Durante quince años el frontend olvidó esa capacidad porque los frameworks construían la página entera en memoria antes de enviarla. Recuperarla es la mejora estructural más grande disponible para cualquier ruta renderizada en servidor: convierte una consulta lenta en un problema que el usuario no ve.
- Explicar qué trabajo del navegador desbloquea el envío temprano de la cabecera.
- Escribir un endpoint con streaming usando flujos web, sin framework.
- Describir el mecanismo del contenido fuera de secuencia y por qué hace falta.
- Diagnosticar las tres causas por las que un streaming correcto deja de funcionar en producción.
Qué desbloquea exactamente
Sin streaming, la secuencia es: el servidor recibe la petición, consulta lo que necesita, monta el HTML completo y lo envía. El navegador no tiene nada que hacer durante todo ese tiempo. Si la consulta tarda 800 ms, son 800 ms de pantalla en blanco durante los cuales el navegador está ocioso teniendo una conexión abierta y capacidad de descarga sin usar.
Con streaming, el servidor envía la cabecera del documento en cuanto la tiene —que es inmediatamente, porque no depende de ningún dato— y sigue enviando a medida que produce. Eso desbloquea tres cosas a la vez.
El escáner de precarga se pone a trabajar. Es un analizador ligero que va por delante del principal buscando recursos que descargar. En cuanto ve la etiqueta de la hoja de estilos, la pide. En cuanto ve la de la fuente, la pide. En cuanto ve el img del hero, la pide. Todo eso ocurre mientras el servidor sigue consultando la base de datos. Los 800 ms de la consulta dejan de ser tiempo perdido y pasan a ser tiempo en el que se descargan los recursos críticos.
El navegador puede pintar. El contenido que llega en el primer trozo —cabecera, navegación, el hero— se renderiza. El FCP deja de depender de la parte lenta de la página.
Se establece la conexión con otros orígenes. Los preconnect de la cabecera se ejecutan pronto, así que cuando el navegador necesite el CDN de imágenes o la API, el apretón de manos ya está hecho.
Hay una técnica que va todavía un paso antes: las pistas tempranas, un código de estado 103 que el servidor puede enviar antes de la respuesta definitiva, con las cabeceras Link de los recursos que va a necesitar.
HTTP/1.1 103 Early Hints
Link: </estilos.css>; rel=preload; as=style
Link: </fuente.woff2>; rel=preload; as=font; crossorigin
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
...
El navegador empieza a descargar en cuanto recibe el 103, aunque el servidor tarde otro segundo en producir el 200. Chromium lo implementa y varias redes de distribución lo generan automáticamente; fuera de Chromium el soporte ha ido más despacio, así que trátalo como una mejora que suma donde existe y no hace daño donde no.
Cómo se hace
Sin framework, con flujos web, que es la API que funciona igual en un tiempo de ejecución de servidor moderno y en un worker del borde:
export async function GET() {
const codificador = new TextEncoder();
const flujo = new ReadableStream({
async start(control) {
// TROZO 1: sale inmediatamente. No depende de ningun dato.
// El escaner de precarga ya puede pedir CSS, fuente e imagen.
control.enqueue(codificador.encode(`<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/estilos.css">
<link rel="preload" as="font" href="/inter.woff2" type="font/woff2" crossorigin>
<link rel="preconnect" href="https://imagenes.ejemplo.com">
<title>Catalogo</title>
</head>
<body>
<header class="cabecera">...</header>
<main>
<img src="https://imagenes.ejemplo.com/hero.avif" alt=""
width="1200" height="600" fetchpriority="high">
<h1>Catalogo</h1>`));
// El servidor puede tardar aqui lo que haga falta: el usuario
// ya esta viendo cabecera, hero y titulo.
const productos = await consultarCatalogo();
// TROZO 2: el contenido que dependia de los datos.
control.enqueue(codificador.encode(renderizarLista(productos)));
control.enqueue(codificador.encode(`
</main>
<script type="module" src="/isla-filtros.js"></script>
</body>
</html>`));
control.close();
},
});
return new Response(flujo, {
headers: {
'content-type': 'text/html; charset=utf-8',
// Le pide al proxy que no acumule la respuesta.
'x-accel-buffering': 'no',
},
});
}
Con framework el mecanismo es el mismo con otra sintaxis. En React, renderToReadableStream y renderToPipeableStream producen un flujo, y los límites de Suspense marcan qué partes pueden llegar más tarde: lo que está dentro de un límite se envía primero como su elemento de reserva y se sustituye cuando esté listo. En Astro, las islas de servidor cumplen la misma función a nivel de componente. En ambos casos, la decisión de diseño que tomas es la misma: dónde pones la frontera entre lo que sale ya y lo que puede esperar.
Un aviso importante que se olvida: el await de la consulta debe estar dentro del flujo, no antes de crearlo. Si consultas y luego construyes la respuesta, no hay streaming aunque uses la API de flujos: has vuelto a bloquear.
El truco del contenido fuera de secuencia
Hay una limitación evidente del streaming tal como lo he descrito: el HTML llega en orden, así que si la parte lenta está arriba, todo lo que va debajo espera. Y en muchas páginas la parte lenta está justo arriba.
La solución es enviar el HTML en un orden distinto del visual, y el mecanismo es más sencillo de lo que parece:
- En la posición visual correcta se envía un marcador con el elemento de reserva —un esqueleto, un indicador de carga— con un identificador.
- Cuando el dato lento está listo, se envía al final del flujo el contenido real dentro de un elemento
template, que el navegador analiza pero no muestra. - Justo después se envía un
scripten línea de tres líneas que mueve el contenido deltemplateal hueco del marcador y borra ambos.
<!-- En su sitio visual, al principio -->
<div id="s1"><div class="esqueleto"></div></div>
<!-- ... el resto de la pagina llega y se renderiza ... -->
<!-- Al final, cuando el dato esta listo -->
<template id="t1"><ul class="recomendaciones">...</ul></template>
<script>
(function () {
var t = document.getElementById('t1');
var s = document.getElementById('s1');
s.replaceWith(t.content);
t.remove();
})();
</script>
Esto es exactamente lo que hace React internamente con sus límites de Suspense, y saberlo cambia cómo se depura: cuando veas plantillas y scripts diminutos al final del HTML de una página renderizada con streaming, no es basura, es el mecanismo.
La consecuencia de producto es que puedes decidir el orden en que la información aparece con independencia de dónde esté colocada. Y la consecuencia de rendimiento que hay que vigilar es el CLS: si el elemento de reserva no ocupa lo mismo que el contenido definitivo, la sustitución desplaza todo lo que hay debajo. Los esqueletos con el tamaño exacto no son un detalle estético.
Lo que rompe el streaming sin avisar
Tres causas, y las tres producen el mismo síntoma: el código es correcto, funciona en local, y en producción la respuesta llega de golpe.
Un intermediario que acumula. Un proxy inverso, un balanceador o una red de distribución mal configurada pueden esperar a tener la respuesta completa antes de reenviarla. En Nginx es proxy_buffering, que se desactiva por directiva o respondiendo con la cabecera X-Accel-Buffering: no. En una red de distribución hay que comprobar explícitamente que soporta y no desactiva el paso a través en streaming. El diagnóstico es directo: mira el desglose de tiempos de la petición en el panel de red; si el tiempo de espera es largo y el de descarga es corto, no hay streaming.
La compresión. Un compresor necesita acumular datos para comprimir bien, así que por defecto suele esperar. Hay que configurarlo para que vacíe su búfer en cada trozo, aceptando una compresión ligeramente peor. Si tu primer trozo son dos kilobytes y el compresor espera a tener ocho, has perdido el streaming por ahorrar cien bytes.
Un error después de haber enviado. En cuanto has enviado el primer byte, el código de estado ya está mandado y las cabeceras también. No puedes responder un 500, no puedes redirigir, no puedes cambiar el tipo de contenido. Si la consulta falla a mitad, lo único que puedes hacer es escribir un mensaje de error en el HTML que ya está llegando. Esto obliga a mover toda la lógica que puede fallar de forma fatal —autenticación, redirecciones, comprobación de existencia del recurso— antes del primer envío. Es la restricción de diseño más importante del streaming y la que más veces se descubre en producción.
Aquí hay una trampa que ha hecho tomar malas decisiones a equipos enteros. El tiempo hasta el primer byte mide, literalmente, cuánto tarda en llegar el primer byte. En una respuesta no fragmentada eso es una aproximación excelente de “cuánto tarda el servidor en hacer su trabajo”, y por eso lleva veinte años usándose como métrica de salud del backend. En una respuesta con streaming es una medida de cuánto tardas en decidir empezar a hablar, que es una cosa completamente distinta y que puedes llevar a cincuenta milisegundos sin haber mejorado absolutamente nada, simplemente vaciando el búfer antes. He visto paneles donde el TTFB del percentil 75 cayó de 900 ms a 60 ms el día que se activó el streaming, y el equipo lo celebró como una mejora de quince veces. La mejora real existía y era importante, pero no era esa: era que el escáner de precarga había ganado 840 milisegundos de ventaja para descargar recursos críticos. Si la consulta lenta hubiera estado bloqueando el elemento que produce el LCP, el LCP no habría mejorado nada y el panel habría seguido enseñando un triunfo. La lección operativa es doble. Primero, en cuanto hagas streaming, degrada el TTFB de métrica de gobierno a métrica de diagnóstico y gobierna con el LCP y con una métrica propia de “tiempo hasta que el contenido principal está en el HTML”, que es la que de verdad describe el trabajo del servidor. Segundo, y más general: cada vez que una optimización mejora espectacularmente una métrica, hay que preguntarse si ha mejorado el fenómeno o si ha cambiado la relación entre el fenómeno y su indicador. Las dos cosas se ven igual en un panel. La diferencia es que una hace más rápida la página y la otra solo hace más rápido el número, y solo se distinguen entendiendo qué mide exactamente el instrumento.
- Coge tu ruta con el servidor más lento y separa el HTML en dos trozos: lo que no depende de datos y lo que sí. Mide el TTFB y el FCP antes y después.
- Verifica en el panel de red que el streaming llega de verdad al navegador y no se acumula en un intermediario.
- Comprueba qué hace tu compresor: mide el tamaño del primer trozo que llega y compáralo con el que envías.
- Mueve toda la lógica que puede devolver un error o una redirección por delante del primer envío. Documenta lo que había.
- Implementa el contenido fuera de secuencia para un bloque lento de la parte alta de la página y mide el efecto en el CLS.