Streaming y Suspense
Transmite contenido renderizado en servidor progresivamente usando límites de Suspense de React y loading.tsx.
Receta
Tarjeta de referencia rápida -- lista para copiar y pegar.
// app/dashboard/loading.tsx -- interfaz de carga instantánea para toda la ruta
export default function Loading() {
return <div className="animate-pulse">Cargando dashboard...</div>;
}// app/dashboard/page.tsx -- streaming granular con Suspense
import { Suspense } from "react";
export default function DashboardPage() {
return (
<main>
<h1>Dashboard</h1>
<Suspense fallback={<p>Cargando estadísticas...</p>}>
<SlowStats />
</Suspense>
<Suspense fallback={<p>Cargando gráfico...</p>}>
<SlowChart />
</Suspense>
</main>
);
}
async function SlowStats() {
const stats = await fetchStats(); // 2s delay
return <StatsGrid data={stats} />;
}Cuándo usarlo: Tienes una página con fuentes de datos lentas y deseas mostrar contenido progresivamente en lugar de bloquear toda la página en la consulta más lenta.
Ejemplo Funcional
// app/dashboard/page.tsx
import { Suspense } from "react";
import { RecentOrders } from "./recent-orders";
import { RevenueChart } from "./revenue-chart";
import { TopProducts } from "./top-products";
export default function DashboardPage() {
return (
<main className="grid grid-cols-2 gap-6 p-6">
<h1 className="col-span-2 text-2xl font-bold">Dashboard</h1>
{/* Cada sección se transmite independientemente */}
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart />
</Suspense>
<Suspense fallback={<ListSkeleton rows={5} />}>
<TopProducts />
</Suspense>
<div className="col-span-2">
<Suspense fallback={<TableSkeleton />}>
<RecentOrders />
</Suspense>
</div>
</main>
);
}
function ChartSkeleton() {
return <div className="h-64 bg-gray-100 rounded animate-pulse" />;
}
function ListSkeleton({ rows }: { rows: number }) {
return (
<div className="space-y-3">
{Array.from({ length: rows }).map((_, i) => (
<div key={i} className="h-8 bg-gray-100 rounded animate-pulse" />
))}
</div>
);
}
function TableSkeleton() {
return <div className="h-48 bg-gray-100 rounded animate-pulse" />;
}// app/dashboard/revenue-chart.tsx (Server Component)
import { db } from "@/lib/db";
import { ChartClient } from "./chart-client";
export async function RevenueChart() {
// Simula una consulta lenta
const revenue = await db.order.aggregate({
_sum: { total: true },
where: { createdAt: { gte: new Date(Date.now() - 30 * 86400000) } },
});
const dailyData = await db.$queryRaw`
SELECT DATE(created_at) as date, SUM(total) as total
FROM orders
WHERE created_at > NOW() - INTERVAL '30 days'
GROUP BY DATE(created_at)
ORDER BY date
`;
return <ChartClient data={dailyData} total={revenue._sum.total ?? 0} />;
}// app/dashboard/loading.tsx
// Este archivo crea automáticamente un límite de Suspense alrededor de la página
export default function DashboardLoading() {
return (
<div className="grid grid-cols-2 gap-6 p-6">
<h1 className="col-span-2 text-2xl font-bold">Dashboard</h1>
<div className="h-64 bg-gray-100 rounded animate-pulse" />
<div className="h-64 bg-gray-100 rounded animate-pulse" />
<div className="col-span-2 h-48 bg-gray-100 rounded animate-pulse" />
</div>
);
}Lo que esto demuestra:
loading.tsxcomo un límite de Suspense a nivel de ruta para retroalimentación de navegación instantánea- Límites de
<Suspense>granulares para que cada widget del dashboard se transmita independientemente - Componentes de esqueleto que coinciden con el diseño del contenido real para prevenir cambios de diseño
- Server Components obteniendo datos de forma asincrónica -- cada uno se resuelve y se transmite cuando está listo
Análisis Profundo
Cómo Funciona
- Streaming SSR: En lugar de esperar a que se resuelvan todos los datos antes de enviar cualquier HTML, Next.js envía el shell inicial inmediatamente y transmite fragmentos HTML adicionales a medida que se resuelve cada límite de Suspense.
loading.tsx: Next.js envuelve automáticamente el componente de página en un límite de<Suspense>usandoloading.tsxcomo fallback. Esto proporciona un estado de carga instantáneo durante la navegación.- Suspense Anidado: Puedes anidar límites de
<Suspense>en cualquier granularidad. Cada límite se resuelve independientemente y reemplaza su fallback con el contenido real. - Orden de resolución: Los componentes se transmiten en el orden en que se resuelven, no en el orden en que aparecen en el árbol. Un widget en la parte inferior de la página puede aparecer antes que uno en la parte superior si sus datos llegan primero.
- Navegación del lado del cliente: Durante la navegación del lado del cliente (usando
<Link>), React renderiza el fallback deloading.tsxinmediatamente mientras obtiene la carga de RSC para la nueva ruta. - Streaming HTTP: La respuesta usa
Transfer-Encoding: chunkedpara enviar HTML progresivamente. Esto requiere un runtime que admita streaming (Node.js, Edge).
Variaciones
Streaming con una promesa pasada (patrón defer):
// Server Component pasa una promesa sin esperar
export default async function Page() {
const analyticsPromise = fetchAnalytics(); // no esperada
return (
<Suspense fallback={<p>Cargando análisis...</p>}>
<Analytics dataPromise={analyticsPromise} />
</Suspense>
);
}
// Client Component consume la promesa con use()
"use client";
import { use } from "react";
export function Analytics({ dataPromise }: { dataPromise: Promise<Data> }) {
const data = use(dataPromise); // se suspende hasta que se resuelve
return <Chart data={data} />;
}Streaming secuencial vs paralelo:
// Secuencial -- cada uno espera en orden (cuello de botella)
async function Sequential() {
const a = await fetchA(); // bloquea
const b = await fetchB(); // espera a
return <>{a}{b}</>;
}
// Paralelo -- límites de Suspense separados
function Parallel() {
return (
<>
<Suspense fallback={<p>A...</p>}><AsyncA /></Suspense>
<Suspense fallback={<p>B...</p>}><AsyncB /></Suspense>
</>
);
}Notas de TypeScript
// loading.tsx debe ser una exportación por defecto que devuelve ReactNode
export default function Loading(): React.ReactNode {
return <Skeleton />;
}
// El fallback de Suspense acepta ReactNode
<Suspense fallback={<div>Cargando...</div>}>
<AsyncComponent />
</Suspense>
// Inferencia de tipo del hook use()
const data: Data = use(dataPromise); // infiere de Promise<Data>Trampas
-
Límite de Suspense muy alto -- Envolver toda tu página en un único límite de Suspense significa que nada se muestra hasta que se resuelven todos los datos. Solución: Usa múltiples límites de Suspense granulares alrededor de cada fuente de datos independiente.
-
Límite de Suspense muy bajo -- Envolver cada componente pequeño en Suspense crea spinners de carga excesivos y ruido visual. Solución: Agrupa componentes relacionados bajo un único límite para una experiencia de carga cohesiva.
-
Cambio de diseño de esqueletos -- Si tu esqueleto no coincide con las dimensiones del contenido real, la página salta cuando llegan los datos. Solución: Haz que los esqueletos tengan la misma altura/ancho que el contenido resuelto usando dimensiones fijas o relaciones de aspecto.
-
loading.tsxse aplica solo a navegaciones -- En una actualización completa (carga de página completa),loading.tsxse renderiza como parte del HTML inicial, pero no crea un verdadero límite de streaming para el SSR inicial en todos los casos. Solución: Usa límites explícitos de<Suspense>dentro de tu página para un comportamiento de streaming confiable. -
Las rutas estáticas no se transmiten -- Si una ruta es completamente estática (sin datos dinámicos), se prerenderiza en el tiempo de compilación y se sirve como un archivo HTML completo. El streaming solo se aplica a rutas dinámicas. Solución: Este es el comportamiento esperado; no se requiere corrección.
-
Límites de error y Suspense -- Si un componente hijo de Suspense lanza un error, el error se propaga. Sin
error.tsxo<ErrorBoundary>, toda la página falla. Solución: Empareja límites de Suspense con límites de error al mismo nivel.
Alternativas
| Alternativa | Úsalo Cuando | No lo Uses Cuando |
|---|---|---|
loading.tsx | Deseas un estado de carga simple a nivel de ruta | Necesitas control granular sobre qué partes se transmiten |
<Suspense> Anidado | Cada sección tiene fuentes de datos independientes | Todos los datos provienen de una única consulta rápida |
| Obtención de datos del lado del cliente (SWR) | Necesitas actualizaciones en tiempo real después de la carga inicial | El streaming del lado del servidor es suficiente |
| Generación estática | Los datos no cambian entre implementaciones | Los datos son específicos del usuario o se actualizan frecuentemente |
| Partial Prerendering (PPR) | Deseas un shell estático con espacios dinámicos | Tu página completa es dinámica |
Ejemplo del Mundo Real
De una aplicación SaaS de Next.js 15 / React 19 en producción (SystemsArchitect.io).
// Ejemplo de producción: streaming SSE con almacenamiento en búfer de markdown
// Archivo: src/hooks/use-stream-content.ts
const reader = response.body?.getReader();
const decoder = new TextDecoder();
let buffer = '';
const hasIncompleteMarkdown = (text: string): boolean => {
const boldCount = (text.match(/\*\*/g) || []).length;
if (boldCount % 2 !== 0) return true;
const codeBlockCount = (text.match(/```/g) || []).length;
if (codeBlockCount % 2 !== 0) return true;
const openBrackets = (text.match(/\[/g) || []).length;
const closeBrackets = (text.match(/\]/g) || []).length;
if (openBrackets !== closeBrackets) return true;
return false;
};
while (true) {
if (options.signal?.aborted) {
setState({ isStreaming: false, streamedContent: accumulated, error: 'Stream aborted' });
return;
}
const { done, value } = await reader.read();
if (done) {
if (buffer) { accumulated += buffer; }
break;
}
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.slice(6));
if (data.chunk) {
buffer += data.chunk;
const { complete, remaining } = extractCompleteUnits(buffer);
if (complete) {
accumulated += complete;
buffer = remaining;
setState({ isStreaming: true, streamedContent: accumulated, error: null });
options.onChunk?.(complete);
}
}
} catch { /* omite líneas SSE mal formadas */ }
}
}
}Lo que esto demuestra en producción:
response.body.getReader()devuelve un ReadableStreamDefaultReader para procesar datos a medida que lleganTextDecoder({ stream: true })maneja caracteres UTF-8 de múltiples bytes que pueden estar divididos entre fragmentoshasIncompleteMarkdown()previene renderizar markdown parcial (marcadores de negrita no coincidentes, bloques de código sin cerrar, enlaces incompletos) que destellaría formato rotooptions.signal?.abortedverifica la señal del AbortController permitiendo al usuario cancelar a mitad de la transmisiónaccumulatedconstruye el contenido completo para guardarlo, mientras quesetStateactiva renderizados de UI incrementales- El patrón buffer asegura que solo se renderizen oraciones y párrafos completos, no tokens parciales
JSON.parseen cada línea SSE podría fallar en datos mal formados. El catch silencioso es el comportamiento correcto para streaming
Preguntas Frecuentes
¿Cuál es la diferencia entre loading.tsx y un límite <Suspense> explícito?
loading.tsxcrea un límite de Suspense automático a nivel de ruta alrededor de toda la página- Los límites explícitos de
<Suspense>dan control granular sobre qué partes se transmiten independientemente - Usa
loading.tsxpara un estado de carga rápido; usa<Suspense>para streaming fino
¿Los componentes se transmiten en el orden en que aparecen en el árbol JSX?
- No. Los componentes se transmiten en el orden en que se resuelven, no por su posición en el árbol
- Un widget en la parte inferior puede aparecer antes que uno en la parte superior si sus datos llegan primero
- Cada límite de Suspense se resuelve independientemente
¿Qué sucede si un límite de Suspense es demasiado alto en el árbol de componentes?
- Nada se muestra hasta que se resuelvan todos los datos dentro de ese límite
- Pierdes el beneficio del renderizado progresivo
- Usa múltiples límites de Suspense granulares alrededor de cada fuente de datos independiente
¿Qué sucede si un componente hijo de Suspense lanza un error?
- El error se propaga más allá del límite de Suspense
- Sin
error.tsxo<ErrorBoundary>, toda la página falla - Siempre empareja límites de Suspense con límites de error al mismo nivel
¿Las rutas estáticas usan streaming?
- No. Las rutas completamente estáticas se prerrenderizan en el tiempo de compilación y se sirven como HTML completo
- El streaming solo se aplica a rutas dinámicas con datos asincronos
- Este es el comportamiento esperado y no requiere corrección
¿Cómo pasas una promesa de un Server Component a un Client Component para streaming?
// Server Component: NO esperes la promesa
export default async function Page() {
const dataPromise = fetchAnalytics();
return (
<Suspense fallback={<p>Cargando...</p>}>
<AnalyticsClient dataPromise={dataPromise} />
</Suspense>
);
}
// Client Component: consume con use()
"use client";
import { use } from "react";
function AnalyticsClient({ dataPromise }: { dataPromise: Promise<Data> }) {
const data = use(dataPromise);
return <Chart data={data} />;
}¿Cómo previene cambios de diseño cuando streaming reemplaza esqueletos con contenido real?
- Haz que los esqueletos coincidan con la altura y ancho del contenido resuelto
- Usa dimensiones fijas o relaciones de aspecto en componentes de esqueleto
- Las dimensiones no coincidentes causan que la página salta cuando llegan los datos
¿Cuál es el tipo de retorno de TypeScript para loading.tsx?
export default function Loading(): React.ReactNode {
return <Skeleton />;
}- Debe ser una exportación por defecto que devuelve
React.ReactNode
¿Qué mecanismo HTTP habilita el streaming en Next.js?
- La respuesta usa
Transfer-Encoding: chunkedpara enviar HTML progresivamente - Esto requiere un runtime que admita streaming (Node.js o Edge)
- El alojamiento estático sin soporte de streaming no puede usar esta característica
¿Cómo infiere el hook use() tipos de una promesa?
const data: Data = use(dataPromise);
// TypeScript infiere Data de Promise<Data> automáticamenteuse()desenvuelve el tipo de promesa, entoncesuse(Promise<T>)devuelveT
¿Por qué loading.tsx no siempre crea un verdadero límite de streaming en cargas de página completa?
- En actualización completa,
loading.tsxse renderiza como parte del HTML inicial - No siempre crea un verdadero límite de streaming para el SSR inicial
- Usa límites explícitos de
<Suspense>dentro de tu página para streaming confiable en carga inicial
¿Qué protege la función hasIncompleteMarkdown en el ejemplo del mundo real?
- Previene renderizar markdown parcial como marcadores de negrita no coincidentes (
**) o bloques de código sin cerrar - Sin esta verificación, tokens incompletos destellarían formato roto al usuario
- El patrón buffer asegura que solo se renderizen oraciones y párrafos completos
Relacionado
- Fetching -- Configurar obtención de datos asincronos
- Caching -- Cómo el almacenamiento en caché interactúa con streaming
- Partial Prerendering -- Shells estáticos con contenido dinámico transmitido
- Server Components -- Componentes asincronos que potencian streaming