En las arquitecturas tradicionales de Server-Side Rendering (SSR) con búfer monolítico, existía un cuello de botella crítico: el servidor estaba obligado a esperar la resolución de todas las operaciones asíncronas (consultas a bases de datos, microservicios y APIs de terceros) antes de poder transmitir el primer byte de HTML generado al cliente.
Si en una página el 95% del contenido es estático y está listo para mostrarse en 20 ms, pero un bloque periférico (como recomendaciones personalizadas o un feed de actividad) tarda 2 segundos en responder, el usuario visualiza una pantalla en blanco durante 2 segundos completos. Esto degrada drásticamente las métricas fundamentales de Core Web Vitals:
Streaming SSR y React Suspense eliminan este bloqueo monolítico. El servidor transmite al instante el armazón estático de la página (Shell), la barra de navegación y los esqueletos de carga, mientras que los componentes más lentos fluyen en streaming hacia el navegador conforme sus datos quedan disponibles.
En el renderizado en streaming, el documento HTML deja de emitirse como un archivo monolítico con una cabecera Content-Length fija. En su lugar, el protocolo conmuta al modo Chunked Transfer Encoding (o multiplexación de tramas de datos en HTTP/2 y HTTP/3).
El servidor clasifica el árbol de componentes en dos niveles:
layout.tsx), la barra de navegación, el pie de página y los esqueletos de reserva. Se remite al cliente en menos de 30–50 ms.Conexión HTTP (Transfer-Encoding: chunked / HTTP/2)
────────────────────────────────────────────────────────────────────────────►
[CHUNK 1: Armazón (Shell) + Esqueletos]
<!DOCTYPE html><html>
<body>
<nav>Navegación del sitio</nav>
<div id="slot-reviews">
<!--$?--><template id="B:0"></template><div class="skeleton">Cargando opiniones...</div><!--/$-->
</div>
</body>
</html>
────────────────────────────────────────────────────────────────────────────► ¡El navegador pinta el FCP!
... (El servidor procesa en paralelo la consulta reviewsDb.findMany()) ...
[CHUNK 2: Marcado resuelto + Script de sustitución DOM]
<div hidden id="S:0">
<div class="reviews-list">Opinión 1, Opinión 2, Opinión 3</div>
</div>
<script>
$RC("B:0", "S:0"); // Función interna de React: intercambia atómicamente B:0 por S:0
</script>
────────────────────────────────────────────────────────────────────────────► ¡El esqueleto se reemplaza
al instante por el contenido!El servidor arranca la evaluación de la ruta e inmediatamente emite la cabecera Transfer-Encoding: chunked, <html>, <head> y el armazón estático.
El navegador procesa el primer chunk y dibuja la navegación y los esqueletos al instante. El TTFB queda fijado en el mínimo absoluto.
El servidor resuelve concurrentemente las consultas a bases de datos de aquellos componentes aislados bajo límites de Suspense.
Conforme concluye cada componente, el servidor envía su HTML acompañado del microscript $RC() que reemplaza el esqueleto de forma nativa en el DOM.
React activa la interactividad de los componentes de cliente individualmente sin esperar al resto de módulos pendientes de la página.
¿De qué manera el navegador incrusta el HTML de un componente en streaming en el lugar exacto del DOM si ese fragmento llega al final de una conexión HTTP abierta?
El archivo loading.tsx envuelve automáticamente al componente hermano page.tsx (y sus descendientes) dentro de una frontera <Suspense fallback={<Loading />}>:
export default function AnalyticsLoading() {
return (
<div className="p-8 space-y-6 animate-pulse">
<div className="h-8 w-64 bg-bg-elevated rounded-lg" />
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="h-32 bg-bg-elevated rounded-xl border border-border" />
<div className="h-32 bg-bg-elevated rounded-xl border border-border" />
<div className="h-32 bg-bg-elevated rounded-xl border border-border" />
</div>
<div className="h-80 bg-bg-elevated rounded-xl border border-border" />
</div>
);
}Confiar únicamente en loading.tsx provoca que la página entera quede a la espera del componente más lento. Para obtener máxima concurrencia, utiliza fronteras <Suspense> independientes:
import { Suspense } from 'react';
import { MetricsOverview } from '@/components/MetricsOverview';
import { RealtimeTrafficChart } from '@/components/RealtimeTrafficChart';
import { TransactionFeed } from '@/components/TransactionFeed';
import { CardSkeleton, ChartSkeleton, FeedSkeleton } from '@/components/skeletons';
export default function AnalyticsPage() {
return (
<main className="p-8 space-y-8 max-w-7xl mx-auto">
<h1 className="text-3xl font-bold text-text-primary">Panel de monitorización</h1>
{/* 1. Métricas: responde velozmente (~100 ms) */}
<Suspense fallback={<CardSkeleton />}>
<MetricsOverview />
</Suspense>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* 2. Gráfico pesado: agregación ClickHouse (~800 ms) */}
<div className="lg:col-span-2">
<Suspense fallback={<ChartSkeleton />}>
<RealtimeTrafficChart />
</Suspense>
</div>
{/* 3. Feed de transacciones en vivo: API externa (~400 ms) */}
<div className="lg:col-span-1">
<Suspense fallback={<FeedSkeleton />}>
<TransactionFeed />
</Suspense>
</div>
</div>
</main>
);
}use())#En React 19, se pueden transferir objetos Promise no resueltos a través de fronteras de Suspense. El Server Component lanza la consulta sin await y el Client Component desenvuelve la información con el hook use():
import { Suspense } from 'react';
import { CustomerReviews } from './CustomerReviews';
import { getReviewsPromise } from '@/lib/db/reviews';
interface PageProps {
params: Promise<{ id: string }>;
}
export default async function ProductPage({ params }: PageProps) {
const { id } = await params;
// Iniciar la consulta SIN await: la promesa empieza a resolverse en streaming
const reviewsPromise = getReviewsPromise(id);
return (
<div className="max-w-4xl mx-auto p-6 space-y-6">
<h1 className="text-2xl font-bold">Ficha de producto #{id}</h1>
<Suspense fallback={<div className="h-40 bg-bg-elevated rounded-lg animate-pulse" />}>
{/* Entregar la promesa no resuelta al componente de cliente */}
<CustomerReviews promise={reviewsPromise} />
</Suspense>
</div>
);
}'use client';
import { use } from 'react';
interface Review {
id: string;
rating: number;
text: string;
}
export function CustomerReviews({ promise }: { promise: Promise<Review[]> }) {
// El hook use() resuelve la promesa e interactúa con el Suspense superior
const reviews = use(promise);
return (
<div className="border border-border rounded-xl p-6 bg-bg-surface space-y-4">
<h2 className="text-lg font-bold">Opiniones de clientes ({reviews.length})</h2>
<div className="divide-y divide-border">
{reviews.map((r) => (
<div key={r.id} className="py-3">
<span className="text-accent-cyan font-semibold">Valoración: {r.rating}/5</span>
<p className="text-text-muted mt-1">{r.text}</p>
</div>
))}
</div>
</div>
);
}connection() de Next.js 15+#En Next.js 15+, para evitar que código dinámico se ejecute durante el prerenderizado estático de compilación, se utiliza la utilidad connection():
import { connection } from 'next/server';
import { redis } from '@/lib/redis';
export async function LiveUserCounter() {
// Esperar explícitamente el establecimiento de la conexión entrante del cliente.
// Notifica a Next.js que este componente debe ejecutarse estrictamente durante el streaming.
await connection();
const activeUsers = await redis.get('online_users_count');
return (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-success/10 border border-success/20 text-success text-xs font-mono">
<span className="w-2 h-2 rounded-full bg-success animate-ping" />
En línea ahora: {activeUsers ?? 0}
</div>
);
}¿Qué ocurre si un usuario pulsa el botón 'Comprar' en un componente de cliente cuyo chunk JavaScript aún no se ha descargado por operar en una red 3G móvil lenta?
Ejecutar un await getGlobalSettings() sin protección directamente en el cuerpo de app/layout.tsx o app/page.tsx impide a Next.js transmitir incluso las etiquetas iniciales <html> y <head>.
Toda la página queda degradada a un SSR bloqueante con búfer y el TTFB aumenta en proporción al tiempo de esa consulta.
Solución: Conserva los layouts raíz lo más síncronos y ligeros posible. Desplaza las consultas asíncronas hacia componentes hoja aislados envueltos en <Suspense fallback={<Skeleton />}>.
El streaming puede provocar incrementos sustanciales de CLS (Cumulative Layout Shift) si un esqueleto de fallback tiene 50px de altura y el componente final ocupa 400px. Al sustituirse el contenido, los bloques inferiores sufren un salto visual brusco.
Solución: Los esqueletos deben reservar con exactitud el volumen físico necesario (empleando min-h-[...], aspect-ratio o estructuras rígidas de cuadrícula) replicando la geometría del componente definitivo.
Segmentar la página en múltiples bloques <Suspense> no soluciona el problema si un único componente anida llamadas secuenciales con await (await getProfile(); await getHistory();).
Solución: Si las peticiones son independientes, ejecútalas en paralelo con Promise.all() o divídelas en componentes aislados con sus respectivas fronteras de Suspense.
// ❌ Cascada oculta dentro de Suspense (1000ms en total)
async function UserActivity({ userId }: { userId: string }) {
const profile = await getProfile(userId); // 500ms
const history = await getHistory(profile.accountId); // 500ms adicionales
return <div>...</div>;
}
// ✅ EJECUCIÓN CONCURRENTE (500ms en total):
async function UserActivityParallel({ userId, accountId }: { userId: string; accountId: string }) {
const [profile, history] = await Promise.all([getProfile(userId), getHistory(accountId)]);
return <div>...</div>;
}Aunque motores de búsqueda como Googlebot admiten streaming y esperan varios segundos a que se resuelvan las promesas, indexadores más simples (rastreadores de redes sociales como Telegram o Twitter) analizan únicamente el primer chunk de HTML.
Envolver encabezados principales <h1>, metadatos o la descripción de un producto en <Suspense> puede propiciar que los rastreadores indexen esqueletos de carga. ¡Conserva siempre el contenido crítico visible inicial en el armazón síncrono!