Next.js
Suspense y streaming
Explica cómo Suspense define estados de espera y cómo streaming entrega regiones progresivamente, con boundaries, fallbacks, status, errores e hidratación.
- Última actualización
- Actualizada
- Nivel
- Aplicación
Next.js
Explica cómo Suspense define estados de espera y cómo streaming entrega regiones progresivamente, con boundaries, fallbacks, status, errores e hidratación.
Suspense permite declarar una frontera visual para trabajo que todavía no puede producir su resultado. Streaming permite que Next.js envíe la parte disponible de la ruta antes de que todo el árbol termine. Ninguno acelera por sí mismo una consulta: reorganizan cuándo aparece cada región y evitan que el recurso más lento bloquee toda la respuesta.
request
→ load header data
→ load dashboard data
→ load recommendations
→ generate complete HTML
→ send responseSi recomendaciones tardan cinco segundos, el usuario espera cinco segundos incluso para ver el encabezado.
Con Suspense y streaming:
request
→ render available shell
→ send header + fallbacks
→ dashboard resolves → stream segment
→ recommendations resolve → stream segmentEl trabajo total puede durar lo mismo, pero la interfaz útil aparece antes.
Suspense boundary
├─ content ready
│ └─ reveal content
└─ content suspended
└─ render fallback temporarily
Server streaming
shell bytes
↓
completed boundary A
↓
completed boundary BSuspense es una regla del árbol React. Streaming es la forma de transportar progresivamente el resultado desde el servidor.
Un componente suspende cuando durante render depende de un recurso compatible que aún no está disponible. En Next.js suele ocurrir con:
use.lazy.Un fetch iniciado dentro de useEffect no activa Suspense del servidor porque el Effect ocurre después de hidratar en cliente.
import { Suspense } from "react";
export default function DashboardPage() {
return (
<main>
<DashboardHeader />
<Suspense fallback={<MetricsSkeleton />}>
<Metrics />
</Suspense>
<Suspense fallback={<RecentOrdersSkeleton />}>
<RecentOrders />
</Suspense>
</main>
);
}Cada región puede terminar independientemente. DashboardHeader forma parte de la shell; los fallbacks reservan el lugar del contenido pendiente.
La convención:
app/dashboard/
├─ loading.tsx
└─ page.tsxcrea automáticamente una Suspense boundary alrededor del contenido del segmento y sus descendientes relevantes.
// app/dashboard/loading.tsx
export default function Loading() {
return <DashboardSkeleton />;
}Es apropiada cuando toda la transición del segmento comparte un estado de carga coherente.
Trabajo realizado por un layout padre puede ocurrir antes de alcanzar esa boundary y bloquear la respuesta. Para una consulta concreta o una región secundaria, coloca Suspense más cerca del componente que espera.
<Suspense fallback={<FullPageSpinner />}>
<EntireDashboard />
</Suspense>Un recurso lento oculta contenido rápido.
20 cards
→ 20 skeletons independientes
→ 20 revelados y cambios visualesFragmenta la experiencia, aumenta markup y puede producir una pantalla inquieta.
La boundary debería corresponder a una unidad visual que el usuario comprende: tabla, panel, recomendaciones, perfil o conversación.
Una shell no debe ser únicamente un spinner central. Puede incluir:
Dashboard shell
├─ breadcrumb
├─ heading
├─ filter controls
├─ metrics skeleton
└─ orders skeletonLa shell ayuda a confirmar que la navegación ocurrió y reduce sensación de bloqueo.
Un buen fallback:
<section aria-busy="true" aria-labelledby="orders-title">
<h2 id="orders-title">Pedidos recientes</h2>
<OrdersSkeleton />
</section>No uses role="alert" para cada skeleton. El loading normal no es una emergencia.
Suspense no paraleliza código secuencial:
const user = await getUser();
const recommendations = await getRecommendations();Si no dependen, inicia ambos antes:
const userPromise = getUser();
const recommendationsPromise = getRecommendations();
const [user, recommendations] = await Promise.all([
userPromise,
recommendationsPromise,
]);O distribuye el trabajo en componentes hermanos bajo boundaries distintas. La estructura de await sigue siendo importante.
const organization = await getOrganization(slug);
const projects = await getProjects(organization.id);La segunda lectura necesita la primera. No existe paralelismo que elimine esa dependencia. Puedes:
<Link> puede prefetchear contenido y hacer que una transición parezca instantánea. Si una ruta dinámica no puede prefetchearse completamente, loading.tsx aporta una respuesta visual inmediata.
Link visible
→ possible prefetch
→ click
→ cached/prefetched shell
→ streamed dynamic contentNo midas únicamente navegación repetida con caché caliente. Prueba acceso directo y red lenta.
Cuando el servidor empieza a enviar la shell, headers y status pueden quedar comprometidos:
200 headers sent
→ shell bytes
→ later component discovers an errorEse error puede mostrarse mediante una boundary, pero no siempre puede convertir la respuesta ya iniciada en un 500 o 404 HTTP.
Resuelve redirects, autorización o ausencia crítica antes del stream cuando el status correcto es contractual.
Suspense maneja espera, no errores. Un error inesperado necesita error.tsx o Error Boundary:
pending resource
→ Suspense fallback
failed resource
→ Error BoundaryColoca boundaries de error según la misma unidad de recuperación. Una región de recomendaciones puede fallar sin derribar checkout; una sesión inválida quizá requiera redirección completa.
Un array vacío no es error ni loading:
const orders = await listOrders();
if (orders.length === 0) {
return <EmptyOrdersState />;
}Distingue:
Cada estado necesita una respuesta diferente.
El servidor puede crear una Promise y pasarla a cliente:
export default function Page() {
const postsPromise = getPosts();
return (
<Suspense fallback={<PostsSkeleton />}>
<Posts postsPromise={postsPromise} />
</Suspense>
);
}"use client";
import { use } from "react";
export function Posts({ postsPromise }: Props) {
const posts = use(postsPromise);
return <PostList posts={posts} />;
}Úsalo cuando una región cliente necesita consumir el recurso. Para mostrar una lista sin interacción, un Server Component simple suele ser más claro.
Durante una actualización, reemplazar inmediatamente una región completa por fallback puede sentirse brusco. React transitions permiten conservar contenido previo mientras llega el siguiente resultado:
current results
→ startTransition navigation/filter
→ keep previous UI with pending indicator
→ reveal next resultsEn Next.js, navegación y router coordinan parte de este comportamiento. Diseña pending localizado y evita bloquear controles no relacionados.
Un dato cacheado puede resolverse antes y entrar en la shell. Un dato request-time puede transmitirse después.
cached product information
→ available quickly
personal cart from cookies
→ dynamic under SuspenseStreaming no reemplaza una política de caché. Una consulta lenta compartible quizá deba cachearse; una consulta personal debe permanecer aislada aunque tarde.
El navegador puede mostrar HTML antes de que Client Components estén hidratadas:
HTML visible
≠ handlers readyReduce JavaScript cliente, prioriza boundaries interactivas importantes y prueba clicks en CPU/red lenta. Una shell con muchos controles visualmente activos pero sin código listo puede crear una falsa sensación de disponibilidad.
Contenido transmitido por el servidor puede formar parte del HTML procesado por crawlers, pero el contenido principal no debería depender únicamente de un Effect cliente si necesitas indexación.
Metadata puede tener su propio comportamiento de streaming según el bot. No conviertas cada fragmento SEO importante en una espera innecesaria.
export default function ProductPage({ params }: PageProps<"/products/[slug]">) {
return (
<main>
<Suspense fallback={<ProductSkeleton />}>
<ProductDetails params={params} />
</Suspense>
<Suspense fallback={<StockSkeleton />}>
<LiveStock params={params} />
</Suspense>
<Suspense fallback={<RecommendationsSkeleton />}>
<Recommendations params={params} />
</Suspense>
</main>
);
}Decisiones:
No existe beneficio en tres boundaries si las tres consultas dependen secuencialmente del mismo loader mal diseñado.
Mide:
Una shell instantánea con el contenido principal cinco segundos después puede seguir siendo una mala experiencia.
next build y producción local.Oculta toda la shell por una consulta secundaria.
Fragmenta UX sin mejorar paralelismo.
Solo coordina espera y revelado.
Crea waterfall cliente y no participa en SSR.
Produce CLS.
Un fallo se propaga más de lo necesario.
Mantiene waterfall aunque existan boundaries.
El stream ya comenzó.
Oculta problemas reales.
loading.tsx crea una boundary de segmento; Suspense local da granularidad.code splitting
→ no descargar todavía un módulo
Suspense
→ declarar una boundary para trabajo pendiente
streaming
→ enviar shell y completar boundaries después
hydration
→ activar únicamente las regiones clienteUna route puede usar las cuatro simultáneamente.
next/dynamic combina React.lazy y Suspense con integración de Next.js:
import dynamic from "next/dynamic";
const Chart = dynamic(() => import("./chart"), {
loading: () => <ChartSkeleton />,
});
export function AnalyticsPanel() {
return <Chart />;
}El módulo chart se separa del chunk inicial y se solicita cuando React intenta renderizarlo.
Code splitting tiene overhead de request, parseo y coordinación. Divide por features significativas.
"use client";
import { useState } from "react";
export function MarkdownEditorLauncher() {
const [Editor, setEditor] = useState<React.ComponentType | null>(null);
const [loading, setLoading] = useState(false);
async function openEditor() {
setLoading(true);
const module = await import("./markdown-editor");
setEditor(() => module.MarkdownEditor);
setLoading(false);
}
if (Editor) return <Editor />;
return (
<button type="button" onClick={openEditor} disabled={loading}>
{loading ? "Cargando editor…" : "Editar contenido"}
</button>
);
}Este patrón no descarga el editor hasta que existe intención. Maneja error con try/catch y ofrece retry en producción.
También puedes prefetchear en hover/focus si la probabilidad de uso es alta, sin esperar al click.
const Editor = dynamic(() =>
import("./editor").then((module) => module.Editor),
);El import debe ser explícito y estar dentro del callback para que el bundler relacione el módulo. No construyas el path desde input.
"use client";
const BrowserMap = dynamic(() => import("./browser-map"), {
ssr: false,
});Solo puede utilizarse dentro de un Client Component. Evita que el componente se renderice en servidor.
Úsalo cuando una dependencia:
window durante import/render.Trade-offs:
Antes de usarlo, intenta mover el acceso a navegador a un Effect o usar una librería compatible con SSR.
Si un Server Component importa dinámicamente un Client Component, la separación de código cliente puede tener limitaciones según el grafo actual. Coloca dynamic() en una boundary cliente cuando necesitas control preciso sobre ssr: false o loading cliente.
Un Server Component propio no necesita ser lazy para reducir JS: su implementación ya no entra al bundle cliente. Lazy loading servidor se relaciona con cuándo se ejecuta/streaming, no con JavaScript del navegador de la misma forma.
import { Suspense } from "react";
export default function ProductPage() {
return (
<main>
<ProductHeader />
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews />
</Suspense>
</main>
);
}Cuando Reviews lee una dependencia compatible que está pendiente, React muestra el fallback más cercano.
Fuentes compatibles incluyen:
lazy/dynamic modules.use.Un fetch iniciado dentro de useEffect no activa esa boundary: ocurre después del commit cliente.
app/products/loading.tsxCrea una Suspense boundary automática para la page descendiente y facilita Instant Loading States.
Usa Suspense manual cuando una sección debe revelar independientemente o cuando un layout contiene data fetching runtime que la boundary del segmento no cubre.
Pregunta:
Una boundary debe coincidir con una unidad de producto, no con cada request técnico.
Bueno cuando conoces geometría y quieres evitar CLS.
Suficiente para contenido pequeño:
<p role="status">Cargando comentarios…</p>Durante filtros o navegación no urgente, mantener resultados stale puede ser mejor que reemplazarlos.
fallback={null} es válido para mejoras no esenciales, pero puede ocultar por qué falta una región.
Un import rechazado o una query fallida necesita Error Boundary:
<ErrorBoundary fallback={<ChartError />}>
<Suspense fallback={<ChartSkeleton />}>
<Chart />
</Suspense>
</ErrorBoundary>En App Router, error.tsx puede cubrir el segmento. Para widgets independientes, una boundary local de librería puede limitar el fallo.
request
→ render shell
→ send shell + fallbacks
→ slow component resolves
→ send segment
→ browser inserts/reconcilesEl usuario recibe navegación y contexto antes de que termine el recurso lento.
Streaming no reduce el tiempo de la query; reduce cuánto bloquea al resto.
Después de iniciar el stream, status/headers están comprometidos. Resuelve redirects y not-found críticos antes si el status importa.
Con cacheComponents: true:
use cache puede entrar como contenido cacheado.export default function DashboardPage() {
return (
<main>
<CachedNavigation />
<Suspense fallback={<UserDashboardSkeleton />}>
<UserDashboard />
</Suspense>
</main>
);
}El fallback forma parte del prerender; la región espera request.
Server Component:
export default function PostsPage() {
const postsPromise = getPosts();
return (
<Suspense fallback={<PostsSkeleton />}>
<Posts postsPromise={postsPromise} />
</Suspense>
);
}Client Component:
"use client";
import { use } from "react";
export function Posts({ postsPromise }: { postsPromise: Promise<Post[]> }) {
const posts = use(postsPromise);
return <PostList posts={posts} />;
}Crear la Promise en servidor evita que se reinicie en cada render cliente. No uses este patrón cuando un Server Component puede renderizar la lista directamente; aporta cuando el cliente necesita consumir el resultado dentro de interacción/Context.
Link adelanta segmentos y loading states.
function preloadEditor() {
void import("./editor");
}
<button onMouseEnter={preloadEditor} onFocus={preloadEditor}>
Abrir editor
</button>Existen APIs para recursos específicos, pero usa integración de framework/tooling antes de añadir tags manuales.
No prefetchees features grandes para todos los usuarios; cambia bytes iniciales por menor latencia futura.
Después de un deploy, una pestaña antigua puede solicitar un chunk eliminado:
old HTML/runtime
→ requests old chunk hash
→ 404 ChunkLoadErrorNext.js versiona assets, pero configura CDN para conservar chunks inmutables durante una ventana y evita eliminar releases inmediatamente.
Recovery:
No hagas reload infinito.
Problema:
download component chunk
→ render component
→ discover data request
→ wait dataServer Components/framework loaders pueden iniciar datos antes de descargar la UI cliente.
También puedes iniciar Promises temprano y pasar resultado. Analiza waterfall en Network/Performance, no solo bundle size.
Una boundary puede mostrar HTML antes de que el chunk cliente esté hidratado. El control visible puede no responder inmediatamente.
Reduce JS, no pongas scripts de terceros críticos antes de hydration y conserva semántica HTML (links/forms) para progressive enhancement.
Para navegación o filtros:
const [isPending, startTransition] = useTransition();
startTransition(() => {
router.push(nextUrl);
});Next router ya integra transitions para navegación. En state local, una transition puede mantener UI anterior mientras una nueva rama suspende.
No acelera la query ni sustituye debounce de red.
aria-busy en la región.Project header → shell/server
Showcase video component → dynamic import on viewport/intent
Project content → cached server
Related projects → Suspense streamed
Contact CTA → shellEl video pesado no compite con el LCP. Related projects puede llegar después. El contenido principal sigue indexable sin Effect cliente.
Compara antes y después; más chunks no garantiza mejora.
Aumenta requests y waterfalls.
Oculta incompatibilidad y elimina HTML inicial.
Borra contexto en cada navegación.
El fallo no tiene recuperación local.
Descubre datos tarde.
Puede reiniciar suspensión.
Vuelve a descargar el coste inicial.
Usuarios con tabs antiguas quedan bloqueados.
ssr:false solo en cliente y tiene coste.ssr:false?await siguen ejecutándose secuencialmente; debes iniciar trabajo paralelo o cambiar dependencias.loading.tsx para el segmento completo; Suspense local para una región con tiempo y fallback propios.Partial Prerendering y Cache Components convierte shell, contenido cacheado y regiones dinámicas en una estrategia completa de route.