Rendimiento de Suspense y Streaming — Límites de carga granulares para un rendimiento percibido más rápido
Receta
// app/dashboard/page.tsx — Límites de Suspense granulares por sección
import { Suspense } from "react";
export default function DashboardPage() {
return (
<div className="grid grid-cols-2 gap-6">
{/* Cada sección se transmite independientemente */}
<Suspense fallback={<StatsSkeleton />}>
<StatsPanel />
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart />
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<RecentOrders />
</Suspense>
<Suspense fallback={<FeedSkeleton />}>
<ActivityFeed />
</Suspense>
</div>
);
}
// Cada Server Component async obtiene sus propios datos
async function StatsPanel() {
const stats = await fetchStats(); // 50ms
return <div>{/* renderizar stats */}</div>;
}
async function RevenueChart() {
const data = await fetchChartData(); // 200ms
return <div>{/* renderizar gráfico */}</div>;
}
async function RecentOrders() {
const orders = await fetchOrders(); // 150ms
return <div>{/* renderizar órdenes */}</div>;
}
async function ActivityFeed() {
const feed = await fetchActivityFeed(); // 300ms
return <div>{/* renderizar feed */}</div>;
}Cuándo usarlo: Para cualquier página con múltiples fuentes de datos o secciones que se cargan a diferentes velocidades. Los límites de Suspense granulares permiten que las secciones rápidas aparezcan inmediatamente mientras que las secciones lentas muestran skeletons.
Ejemplo en funcionamiento
// ---- ANTES: Suspense de un solo nivel superior — pantalla en blanco de 1200ms ----
// app/dashboard/page.tsx
import { Suspense } from "react";
export default function DashboardPage() {
return (
// ANTI-PATRÓN: Un límite gigante — nada se muestra hasta que TODOS los datos se cargan
<Suspense fallback={<FullPageLoader />}>
<DashboardContent />
</Suspense>
);
}
// Este componente espera TODOS los datos antes de renderizar nada
async function DashboardContent() {
// Estos se ejecutan secuencialmente (¡cascada!) — total: 50+200+150+300+500 = 1200ms
const stats = await fetchStats(); // 50ms
const chart = await fetchChartData(); // 200ms
const orders = await fetchOrders(); // 150ms
const feed = await fetchActivityFeed(); // 300ms
const recommendations = await fetchRecommendations(); // 500ms
return (
<div className="grid grid-cols-2 gap-6">
<StatsPanel data={stats} />
<RevenueChart data={chart} />
<OrderTable data={orders} />
<ActivityFeed data={feed} />
<Recommendations data={recommendations} />
</div>
);
}
function FullPageLoader() {
return (
<div className="flex items-center justify-center h-screen">
<div className="animate-spin h-8 w-8 border-4 border-blue-500 rounded-full border-t-transparent" />
</div>
);
}
// ---- DESPUÉS: Suspense granular + fetching en paralelo — primer paint en 50ms, completo en 500ms ----
// app/dashboard/page.tsx
import { Suspense } from "react";
export default function DashboardPage() {
return (
<div className="grid grid-cols-2 gap-6">
{/* Prioridad: Stats aparecen primero (50ms) */}
<Suspense fallback={<StatsSkeleton />}>
<StatsPanel />
</Suspense>
{/* Sección de gráfico (200ms) */}
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart />
</Suspense>
{/* Tabla de órdenes (150ms) */}
<Suspense fallback={<TableSkeleton />}>
<OrderTable />
</Suspense>
{/* Activity feed (300ms) */}
<Suspense fallback={<FeedSkeleton />}>
<ActivityFeed />
</Suspense>
{/* Baja prioridad: Recomendaciones (500ms) — Suspense anidada */}
<div className="col-span-2">
<Suspense fallback={<RecommendationsSkeleton />}>
<Recommendations />
</Suspense>
</div>
</div>
);
}
// Cada componente obtiene sus propios datos independientemente
async function StatsPanel() {
const stats = await fetchStats(); // 50ms — primero en transmitirse
return (
<div className="grid grid-cols-3 gap-4">
<div className="p-4 bg-white rounded-lg shadow">
<p className="text-sm text-gray-500">Ingresos</p>
<p className="text-2xl font-bold">${stats.revenue.toLocaleString()}</p>
</div>
<div className="p-4 bg-white rounded-lg shadow">
<p className="text-sm text-gray-500">Órdenes</p>
<p className="text-2xl font-bold">{stats.orders}</p>
</div>
<div className="p-4 bg-white rounded-lg shadow">
<p className="text-sm text-gray-500">Clientes</p>
<p className="text-2xl font-bold">{stats.customers}</p>
</div>
</div>
);
}
async function RevenueChart() {
const data = await fetchChartData(); // 200ms
return (
<div className="bg-white p-4 rounded-lg shadow">
<h3 className="font-semibold mb-2">Ingresos en el tiempo</h3>
{/* El componente Chart se renderiza aquí */}
<div className="h-64">{/* ... */}</div>
</div>
);
}
async function OrderTable() {
const orders = await fetchOrders(); // 150ms
return (
<div className="bg-white rounded-lg shadow overflow-hidden">
<h3 className="font-semibold p-4 border-b">Órdenes recientes</h3>
<table className="w-full">
<tbody>
{orders.map((order) => (
<tr key={order.id} className="border-b last:border-0">
<td className="p-3">{order.customer}</td>
<td className="p-3">${order.total}</td>
<td className="p-3 text-gray-400">{order.status}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
async function ActivityFeed() {
const feed = await fetchActivityFeed(); // 300ms
return (
<div className="bg-white rounded-lg shadow p-4">
<h3 className="font-semibold mb-2">Actividad</h3>
<ul className="space-y-2">
{feed.map((item) => (
<li key={item.id} className="text-sm">
<span className="font-medium">{item.user}</span> {item.action}
</li>
))}
</ul>
</div>
);
}
async function Recommendations() {
const recs = await fetchRecommendations(); // 500ms — más lenta, se carga última
return (
<div className="bg-white rounded-lg shadow p-4">
<h3 className="font-semibold mb-2">Acciones recomendadas</h3>
<div className="grid grid-cols-3 gap-4">
{recs.map((rec) => (
<div key={rec.id} className="p-3 bg-blue-50 rounded">
<p className="font-medium">{rec.title}</p>
<p className="text-sm text-gray-600">{rec.description}</p>
</div>
))}
</div>
</div>
);
}
// Componentes skeleton para estados de carga
function StatsSkeleton() {
return (
<div className="grid grid-cols-3 gap-4">
{[...Array(3)].map((_, i) => (
<div key={i} className="p-4 bg-white rounded-lg shadow animate-pulse">
<div className="h-4 bg-gray-200 rounded w-16 mb-2" />
<div className="h-8 bg-gray-200 rounded w-24" />
</div>
))}
</div>
);
}
function ChartSkeleton() {
return <div className="bg-white p-4 rounded-lg shadow h-72 animate-pulse" />;
}
function TableSkeleton() {
return (
<div className="bg-white rounded-lg shadow p-4 animate-pulse">
{[...Array(5)].map((_, i) => (
<div key={i} className="h-8 bg-gray-200 rounded mb-2" />
))}
</div>
);
}
function FeedSkeleton() {
return (
<div className="bg-white rounded-lg shadow p-4 animate-pulse">
{[...Array(4)].map((_, i) => (
<div key={i} className="h-6 bg-gray-200 rounded mb-2" />
))}
</div>
);
}
function RecommendationsSkeleton() {
return (
<div className="grid grid-cols-3 gap-4">
{[...Array(3)].map((_, i) => (
<div key={i} className="h-24 bg-gray-100 rounded animate-pulse" />
))}
</div>
);
}Lo que demuestra esto:
- Antes: Límite de Suspense único con fetches secuenciales = pantalla en blanco de 1200ms
- Después: 5 límites de Suspense independientes con fetching en paralelo
- Stats aparecen en 50ms, órdenes en 150ms, gráfico en 200ms, feed en 300ms, recomendaciones en 500ms
- El usuario ve contenido significativo en 50ms en lugar de 1200ms (mejora del 96% en el primer paint)
- Cada sección se transmite independientemente — las secciones rápidas no esperan a las lentas
Análisis profundo
Cómo funciona
- Streaming SSR — Next.js envía la shell HTML inicial inmediatamente, luego transmite el contenido de cada límite de Suspense a medida que se resuelve. El navegador renderiza progresivamente el contenido sin esperar a que la página completa esté lista.
- Hidratación selectiva — React hidrata cada límite de Suspense independientemente. Si el usuario interactúa con una sección que ya se ha transmitido, React prioriza la hidratación de esa sección primero.
- Fetching de datos en paralelo — Cada Server Component async dentro de un límite de Suspense separado obtiene datos independientemente. A diferencia de las llamadas secuenciales de
await, estas se ejecutan en paralelo porque React comienza a renderizar todos los hermanos concurrentemente. - loading.tsx — Next.js envuelve automáticamente el contenido de la página en un límite de Suspense con
loading.tsxcomo fallback. Esto proporciona carga a nivel de ruta sin Suspense manual. - Suspense anidada — Los límites de Suspense pueden anidarse. Un límite externo muestra su fallback hasta que el componente async externo se resuelva, luego un límite interno muestra su propio fallback para componentes async internos. Esto crea revelación progresiva.
Variaciones
loading.tsx para Suspense a nivel de ruta:
// app/dashboard/loading.tsx — límite de Suspense automático para la ruta
export default function DashboardLoading() {
return (
<div className="grid grid-cols-2 gap-6">
<StatsSkeleton />
<ChartSkeleton />
<TableSkeleton />
<FeedSkeleton />
</div>
);
}
// No necesitas Suspense manual en page.tsx — loading.tsx la envuelve automáticamenteSuspense anidada para revelación progresiva:
async function OrderSection() {
const summary = await fetchOrderSummary(); // 100ms — rápido
return (
<div>
<h2>Órdenes: {summary.total}</h2>
{/* El límite interno muestra skeleton mientras se cargan los detalles */}
<Suspense fallback={<DetailsSkeleton />}>
<OrderDetails /> {/* 400ms — lento */}
</Suspense>
</div>
);
}Streaming con límites de error:
import { Suspense } from "react";
import { ErrorBoundary } from "react-error-boundary";
function DashboardSection({ children, fallback, errorFallback }) {
return (
<ErrorBoundary fallback={errorFallback}>
<Suspense fallback={fallback}>{children}</Suspense>
</ErrorBoundary>
);
}
// Cada sección maneja sus propios errores sin romper la página
export default function Dashboard() {
return (
<div className="grid grid-cols-2 gap-6">
<DashboardSection
fallback={<StatsSkeleton />}
errorFallback={<p>No se pudieron cargar las estadísticas</p>}
>
<StatsPanel />
</DashboardSection>
<DashboardSection
fallback={<ChartSkeleton />}
errorFallback={<p>No se pudo cargar el gráfico</p>}
>
<RevenueChart />
</DashboardSection>
</div>
);
}Notas de TypeScript
Suspenseaceptafallback: React.ReactNodeechildren: React.ReactNode.- Los Server Components async devuelven
Promise<JSX.Element>— TypeScript lo maneja automáticamente. loading.tsxdebe exportar un componente predeterminado (no async).
Trampas comunes
-
Límite de Suspense único en el nivel superior — Envolver la página completa en un límite de Suspense significa que nada se renderiza hasta que todos los datos se cargan. Esta es la misma experiencia de usuario que un spinner de carga. Solución: Usa límites de Suspense granulares por sección para que las secciones rápidas aparezcan inmediatamente.
-
Fetches secuenciales dentro de un componente —
await fetchA(); await fetchB();crea una cascada incluso con Suspense. Solución: UsaPromise.all([fetchA(), fetchB()])o divide en componentes async separados cada uno con su propio límite de Suspense. -
Desajuste de layout del skeleton — Si la altura del skeleton no coincide con el contenido cargado, la página se desplaza cuando el contenido se transmite, causando CLS. Solución: Diseña skeletons que coincidan estrechamente con las dimensiones del contenido final. Usa alturas fijas o relaciones de aspecto.
-
Límites de Suspense demasiado granulares — Envolver cada componente en Suspense crea un efecto "popcorn" donde docenas de pequeñas secciones aparecen en diferentes momentos. Solución: Agrupa el contenido relacionado en secciones lógicas, cada una con un límite de Suspense.
-
Límites de error faltantes — Sin un límite de error alrededor de cada límite de Suspense, una sección fallida bloquea la página completa. Solución: Envuelve cada Suspense en un
ErrorBoundarypara que las secciones fallidas muestren un mensaje de error mientras el resto de la página continúa funcionando. -
Streaming deshabilitado por cookies/headers dinámicos — Leer cookies o headers en un layout opt-out del renderizado estático de todo el subárbol y puede afectar el comportamiento del streaming. Solución: Mueve las llamadas a
cookies()yheaders()en los Server Components específicos que las necesitan.
Alternativas
| Enfoque | Compromiso |
|---|---|
| Límites de Suspense granulares | Mejor UX de streaming; requiere diseño de skeleton por sección |
Único loading.tsx | Simple; bloquea toda la ruta hasta que se resuelve el componente de página |
| Fetching del lado del cliente (SWR o TanStack Query) | Navegación instantánea; muestra estados de carga, más JS del cliente |
| Generación estática (SSG) | Tiempo de carga cero; los datos pueden estar obsoletos |
| Regeneración estática incremental (ISR) | Páginas estáticas en caché con actualizaciones periódicas; stale-while-revalidate |
| Renderizado parcial previo (PPR) | Shell estático + partes dinámicas en streaming; experimental en Next.js |
Preguntas frecuentes
¿Qué es el streaming de Suspense y cómo mejora el rendimiento percibido?
El streaming de Suspense envía la shell HTML inicial inmediatamente, luego transmite el contenido de cada límite de Suspense a medida que se resuelve. El navegador renderiza progresivamente el contenido sin esperar a que la página completa esté lista. Las secciones rápidas aparecen primero mientras que las secciones lentas muestran skeletons.
¿Por qué deberías usar límites de Suspense granulares en lugar de uno por página?
Un límite de Suspense único en el nivel superior significa que nada se renderiza hasta que todos los datos se cargan -- la misma UX que un spinner de página completa. Los límites granulares permiten que cada sección se transmita independientemente, para que una búsqueda de 50ms se muestre inmediatamente mientras una búsqueda de 500ms sigue cargándose.
¿Cómo obtienen datos en paralelo los Server Components async dentro de límites de Suspense separados?
React comienza a renderizar todos los componentes hermanos simultáneamente. Cada Server Component async dentro de su propio límite de Suspense obtiene datos independientemente. A diferencia de las llamadas secuenciales de await, estas se ejecutan en paralelo automáticamente.
¿Qué es loading.tsx y cómo se relaciona con Suspense?
loading.tsx en un directorio de ruta de Next.js envuelve automáticamente el contenido de la página en un límite de Suspense usando el componente exportado como fallback. Proporciona carga a nivel de ruta sin Suspense manual.
¿Qué es Suspense anidada y cuándo deberías usarla?
Los límites de Suspense pueden anidarse. El límite externo muestra su fallback hasta que se resuelva el componente async externo, luego los límites internos muestran sus propios fallbacks. Úsalo para revelación progresiva -- muestra un resumen rápidamente, luego carga detalles.
async function OrderSection() {
const summary = await fetchOrderSummary(); // 100ms
return (
<div>
<h2>Órdenes: {summary.total}</h2>
<Suspense fallback={<DetailsSkeleton />}>
<OrderDetails /> {/* 400ms */}
</Suspense>
</div>
);
}Trampa: ¿Cómo crean una cascada los awaits secuenciales dentro de un componente incluso con Suspense?
await fetchA(); await fetchB(); se ejecuta secuencialmente independientemente de los límites de Suspense. El tiempo total es la suma, no el máximo.
Solución: Usa Promise.all([fetchA(), fetchB()]) o divide en componentes async separados cada uno con su propio límite de Suspense.
Trampa: ¿Cómo pueden los desajustes de layout del skeleton causar problemas de CLS?
Si la altura del skeleton no coincide con el contenido cargado, la página se desplaza cuando el contenido se transmite, causando Cumulative Layout Shift. Diseña skeletons que coincidan estrechamente con las dimensiones del contenido final usando alturas fijas o relaciones de aspecto.
¿Por qué deberías envolver cada límite de Suspense con un ErrorBoundary?
Sin un límite de error, una sección fallida bloquea la página completa. Con límites de error, las secciones fallidas muestran un mensaje de error mientras el resto de la página continúa funcionando normalmente.
¿Qué significa "hidratación selectiva" en el contexto del streaming?
React hidrata cada límite de Suspense independientemente. Si el usuario interactúa con una sección que ya se ha transmitido, React prioriza la hidratación de esa sección primero, haciéndola interactiva más rápidamente.
¿Puede la lectura de cookies o headers afectar el comportamiento del streaming?
Sí. Leer cookies() o headers() en un layout opt-out del renderizado estático de todo el subárbol y puede afectar el comportamiento del streaming. Mueve estas llamadas en los Server Components específicos que las necesitan.
¿Cuál es el tipo de TypeScript para la exportación de loading.tsx?
loading.tsx debe exportar un componente predeterminado (no async). Suspense acepta fallback: React.ReactNode e children: React.ReactNode. Los Server Components async devuelven Promise<JSX.Element> que TypeScript maneja automáticamente.
¿Qué es el efecto de carga "popcorn" y cómo lo evitas?
Los límites de Suspense demasiado granulares hacen que docenas de pequeñas secciones aparezcan en diferentes momentos. Agrupa el contenido relacionado en secciones lógicas, cada una con un límite de Suspense, para crear una experiencia de carga más suave.
Relacionado
- Server Component Performance — Mover fetching de datos al servidor para cero JS del cliente
- Data Fetching Performance — Fetching en paralelo y eliminación de cascadas
- Core Web Vitals — Impacto del streaming en LCP, FCP y TTFB
- Next.js Caching — Caching de respuestas transmitidas