En la era clásica de React, el renderizado en servidor dependía del método síncrono renderToString. Su arquitectura adolecía de un defecto crítico de búfer de tipo «todo o nada»: el servidor debía esperar la respuesta de cada base de datos y microservicio para cada componente de la página, concatenar síncronamente una cadena gigantesca en la memoria heap de Node.js y solo entonces emitir el primer byte a la red. Si un solo bloque secundario se demoraba un segundo, tanto los usuarios como los bots de búsqueda se enfrentaban a una pantalla en blanco y la métrica TTFB (Time to First Byte) se degradaba drásticamente.
React 19 consolida el renderizado en servidor mediante streaming (Streaming SSR) como el estándar moderno:
El Streaming SSR solventa los principales cuellos de botella del renderizado tradicional en servidor:
head, sin esperar a que se completen los datos de servidor.<Suspense> no provoca un HTTP 500 general; el servidor emite un fallback localizado, preservando una interfaz funcional para el usuario.El núcleo conceptual del streaming SSR reside en dividir la página en una Carcasa Crítica (Shell) e islas asíncronas aisladas mediante límites <Suspense>.
El servidor no espera a las promesas internas. En cuanto el layout síncrono exterior (la carcasa) termina de renderizarse, se dispara el callback onShellReady. El servidor emite inmediatamente las cabeceras HTTP 200 y vuelca la carcasa con los esqueletos de carga.
Cuando un componente asíncrono diferido resuelve su consulta a la base de datos, React añade al mismo flujo HTTP abierto un contenedor HTML oculto y un micro-script en línea ($RC("S:0","H:0"), React Replace Content). El navegador ejecuta este script de forma nativa durante el parseo continuo del HTML, sustituyendo el esqueleto por los nodos DOM reales antes incluso de que el bundle principal de React de cliente haya terminado de descargarse e hidratar!
El servidor invoca renderToPipeableStream. El motor recorre el árbol síncrono hasta los límites de Suspense.
Se genera el HTML base con esqueletos. El servidor envía cabeceras HTTP 200 y conecta pipe(res).
El navegador recibe el head y dibuja la estructura interactiva con esqueletos (FCP inferior a 40 ms).
El backend completa las consultas, genera el bloque HTML oculto e inyecta el script $RC.
El script $RC sustituye el esqueleto por el contenido real en el DOM antes de que React cliente se ejecute.
¿De qué manera el navegador muestra el contenido HTML real en el lugar del esqueleto durante el Streaming SSR antes de que el bundle JavaScript de React se descargue e inicie la hidratación?
Entrega instantánea de la estructura base y cabecera del catálogo en menos de 20 ms, mientras que el cálculo de descuentos personalizados, recomendaciones y stock en tiempo real se envían en fragmentos continuos.
Diferenciación lógica según la cabecera User-Agent: los usuarios humanos reciben una respuesta inmediata con onShellReady, mientras que los bots de indexación esperan la resolución completa con onAllReady para obtener un documento estático íntegro.
Generación del primer byte en proximidad geográfica directa con el usuario (latencia inferior a 10 ms) usando renderToReadableStream sin el arranque en frío de las máquinas virtuales tradicionales.
Interrupción controlada de peticiones atascadas mediante temporizadores con abort(), activando fallbacks de cliente sin romper la conexión HTTP global.
El servidor invoca renderToPipeableStream. Se procesa el árbol síncrono hasta los límites de Suspense.
Carcasa lista. El servidor emite HTTP 200, envía cabeceras y canaliza el stream al socket (pipe(res)).
Las promesas en Suspense se completan. React emite contenedores ocultos con HTML y el script $RC.
Todos los límites resueltos. React emite el fragmento cero de cierre y concluye la conexión HTTP.
renderToPipeableStream recibe un objeto de configuración con callbacks específicos:
| Callback | Condición de activación | Propósito arquitectónico |
| :--- | :--- | :--- |
| onShellReady | Se completa la carcasa síncrona (todo lo externo a <Suspense>) | Emitir inmediatamente HTTP 200 a usuarios reales; invocar pipe(res) |
| onAllReady | Todos los límites Suspense del árbol han finalizado su carga | Emitir respuesta monolítica para bots y motores de búsqueda |
| onShellError | Se produce una excepción dentro de la carcasa crítica | Devolver código HTTP 500 con página de error estática de emergencia |
| onError | Se produce un error en cualquier nodo (incluyendo Suspense interno) | Registro centralizado en plataformas de observabilidad (Sentry, OpenTelemetry) |
A continuación se muestra un servidor de producción que incorpora detección de bots, control de timeout mediante abort() y gestión nativa de contrapresión (Backpressure):
import express, { Request, Response } from 'express';
import { renderToPipeableStream } from 'react-dom/server';
import React from 'react';
import App from '../client/App';
const app = express();
// Expresión regular para detección de rastreadores SEO
const BOT_AGENTS_REGEX = /Googlebot|bingbot|Baiduspider|DuckDuckBot|YandexBot|Sogou|Slurp/i;
function isCrawler(userAgent: string | undefined): boolean {
if (!userAgent) return false;
return BOT_AGENTS_REGEX.test(userAgent);
}
app.get('*', (req: Request, res: Response) => {
let didError = false;
const isBot = isCrawler(req.headers['user-agent']);
// Tiempo límite para abortar peticiones estancadas (5 segundos)
const ABORT_DELAY_MS = 5000;
const { pipe, abort } = renderToPipeableStream(
<App url={req.url} />,
{
bootstrapScripts: ['/assets/bundle.client.js'],
onShellReady() {
// PARA USUARIOS REALES: ¡Emitir primer byte de inmediato!
if (!isBot) {
res.statusCode = didError ? 500 : 200;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.setHeader('Transfer-Encoding', 'chunked');
// Conexión del flujo al socket de Express con control de Backpressure
pipe(res);
}
},
onAllReady() {
// PARA BOTS DE BÚSQUEDA: Esperar a que todo el contenido en Suspense esté resuelto
if (isBot) {
res.statusCode = didError ? 500 : 200;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
pipe(res);
}
},
onShellError(error: unknown) {
// Fallo crítico en la carcasa: devolver 500 con HTML de emergencia
console.error('[Shell Error]:', error);
res.status(500).send(
'<!DOCTYPE html><html><head><title>Error</title></head><body><h1>Servicio temporalmente no disponible</h1></body></html>'
);
},
onError(error: unknown) {
didError = true;
// Registro de fallos en bloques Suspense internos (el stream no se interrumpe)
console.error('[Suspense Render Error]:', error);
},
}
);
// Temporizador de seguridad: abortar si la base de datos no responde a tiempo
setTimeout(() => {
abort();
}, ABORT_DELAY_MS);
});
app.listen(3000, () => {
console.log('Servidor de Streaming SSR iniciado en http://localhost:3000');
});En plataformas serverless y perimetrales (Cloudflare Workers, Vercel Edge Runtime) se utiliza el estándar W3C Web Streams. La función renderToReadableStream devuelve una promesa que se resuelve con una instancia de ReadableStream:
import { renderToReadableStream } from 'react-dom/server.edge';
import React from 'react';
import App from '../client/App';
export default {
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
const controller = new AbortController();
// Timeout de seguridad para el Edge Worker (3 segundos)
const timeoutId = setTimeout(() => {
controller.abort();
}, 3000);
try {
const stream = await renderToReadableStream(
<App url={url.pathname} />,
{
signal: controller.signal,
bootstrapScripts: ['/assets/bundle.client.js'],
onError(error: unknown) {
console.error('[Edge Streaming Error]:', error);
},
}
);
clearTimeout(timeoutId);
// Respuesta nativa con stream en el cuerpo
return new Response(stream, {
status: 200,
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-transform',
},
});
} catch (err: unknown) {
clearTimeout(timeoutId);
console.error('[Edge Shell Fatal]:', err);
return new Response('<h1>Error interno en Edge Rendering</h1>', {
status: 500,
headers: { 'Content-Type': 'text/html; charset=utf-8' },
});
}
},
};¿Por qué emitir la respuesta en onAllReady en lugar de onShellReady es la práctica recomendada para los rastreadores de búsqueda (Googlebot)?
Para estandarizar los puntos de entrada del servidor, implementamos una utilidad que encapsula la lógica de streaming, métricas de TTFB, discriminación de bots y temporizadores de cancelación:
import { Response } from 'express';
import { renderToPipeableStream } from 'react-dom/server';
import React from 'react';
interface StreamingOptions {
component: React.ReactElement;
bootstrapScripts: string[];
userAgent?: string;
timeoutMs?: number;
onMetrics?: (ttfbMs: number, totalMs: number) => void;
}
export function createStreamingResponse(
res: Response,
options: StreamingOptions
): void {
const {
component,
bootstrapScripts,
userAgent,
timeoutMs = 6000,
onMetrics,
} = options;
const startTime = performance.now();
let ttfbRecorded = false;
let didError = false;
const isCrawler = Boolean(
userAgent && /Googlebot|bingbot|YandexBot|Baiduspider/i.test(userAgent)
);
const { pipe, abort } = renderToPipeableStream(component, {
bootstrapScripts,
onShellReady() {
if (!isCrawler) {
const ttfb = performance.now() - startTime;
ttfbRecorded = true;
res.statusCode = didError ? 500 : 200;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
pipe(res);
if (onMetrics) {
onMetrics(ttfb, 0);
}
}
},
onAllReady() {
const totalTime = performance.now() - startTime;
if (isCrawler) {
res.statusCode = didError ? 500 : 200;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
pipe(res);
}
if (onMetrics && ttfbRecorded) {
onMetrics(0, totalTime);
}
},
onShellError(err: unknown) {
console.error('[Streaming Shell Critical]:', err);
res.status(500).send('<!DOCTYPE html><html><body>Server Error</body></html>');
},
onError(err: unknown) {
didError = true;
console.error('[Streaming Inner Warning]:', err);
},
});
// Prevención de bloqueo indefinido del socket
setTimeout(() => {
abort();
}, timeoutMs);
}El error arquitectónico más frecuente al adoptar renderToPipeableStream es esperar a onAllReady antes de invocar pipe(res) para usuarios normales. Esto neutraliza por completo los beneficios del streaming SSR, obligando al usuario a esperar a la consulta más lenta de la página y degradando el rendimiento al nivel del obsoleto renderToString.
// ❌ INCORRECTO: Anula las ventajas de streaming; el usuario espera la consulta más lenta
renderToPipeableStream(<App />, {
onAllReady() {
pipe(res); // ¡El TTFB se dispara a 1.5 segundos!
}
});
// ✅ CORRECTO: Emisión inmediata de la carcasa en onShellReady
renderToPipeableStream(<App />, {
onShellReady() {
pipe(res); // ¡El TTFB baja a 20-30 ms!
}
});Si un servicio remoto (motor de recomendaciones o base de datos) queda bloqueado por degradación de red y el servidor carece de un temporizador con llamada a abort(), la conexión HTTP con el cliente permanecerá abierta de forma indefinida. Esto produce fugas de descriptores de archivos en el sistema operativo y saturación del servidor bajo carga.
Si un componente asíncrono pesado se ubica en la raíz del árbol sin estar envuelto en un límite <Suspense>, el motor de React lo categoriza como parte de la Carcasa Crítica (Shell). El evento onShellReady no se disparará hasta que dicho componente finalice, bloqueando la emisión de las cabeceras HTML iniciales y destruyendo el TTFB.
Cuando se produce un fallo durante la construcción de la carcasa (por ejemplo, un error en props de un layout crítico), se activa onShellError y onShellReady nunca llega a llamarse. Si el código no gestiona onShellError, el servidor no responderá al cliente y el navegador abortará por timeout con el error ERR_EMPTY_RESPONSE.