On this page · sections
Streaming & Suspense
Streaming SSR sends HTML to the browser progressively instead of waiting for all server data to resolve. React 18’s streaming renderer powers this in the App Router via Suspense boundaries and the loading.tsx convention.
| Mode | TTFB | User sees | Trade-off |
|---|---|---|---|
| Blocking SSR | Waits for all data | Complete page at once | Slow DB = slow first byte |
| Streaming SSR | Shell sent immediately | Layout + skeletons, then chunks | LCP element must be in an early segment |
How streaming differs from blocking SSR
Definition
Blocking SSR waits for every async operation before sending any HTML. Streaming SSR flushes the shell and Suspense fallbacks first, then streams resolved segments as they complete.
// app/dashboard/page.tsx
import { Suspense } from "react";
async function SlowMetrics() {
const data = await fetchMetrics(); // 2s DB query
return <pre>{JSON.stringify(data, null, 2)}</pre>;
}
export default function DashboardPage() {
return (
<main>
<h1>Dashboard</h1>
<Suspense fallback={<div className="skeleton">Loading metrics…</div>}>
<SlowMetrics />
</Suspense>
</main>
);
}Timeline comparison:
Blocking:
[wait 2s for all data] → send complete HTML → TTFB = 2s
Streaming:
[0ms] send shell + skeleton → TTFB ≈ fast
[2s] stream metrics chunk → content appears progressivelyInterview: Streaming changes delivery timing, not where render happens. Content is still server-rendered — just in chunks.
Suspense boundaries in App Router
Definition
Wrap slow async Server Components in Suspense. The fallback renders immediately; the resolved component streams in when ready.
// app/product/[id]/page.tsx
async function ProductHeader({ id }: { id: string }) {
const p = await fetchProduct(id);
return <h1>{p.name}</h1>;
}
async function Reviews({ id }: { id: string }) {
const reviews = await fetchReviews(id);
return (
<ul>
{reviews.map((r) => (
<li key={r.id}>{r.text}</li>
))}
</ul>
);
}
export default async function ProductPage(props: {
params: Promise<{ id: string }>;
}) {
const { id } = await props.params;
return (
<>
<Suspense fallback={<div className="skeleton h-8 w-48" />}>
<ProductHeader id={id} />
</Suspense>
<Suspense fallback={<div className="skeleton h-32" />}>
<Reviews id={id} />
</Suspense>
</>
);
}Parallel async components inside separate Suspense boundaries resolve concurrently — not sequentially.
Interview: Avoid one Suspense boundary around the entire page if nav is fast. Split critical vs slow into separate boundaries.
loading.tsx convention
Definition
loading.tsx is a file-convention Suspense wrapper for a route segment. It shows automatically while the segment’s async work suspends.
// app/shop/loading.tsx
export default function Loading() {
return <div className="skeleton grid" aria-busy="true" />;
}
// app/shop/[slug]/page.tsx
export default async function ProductPage(props: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await props.params;
const product = await fetchProduct(slug);
return <h1>{product.name}</h1>;
}On soft navigation, loading.tsx appears instantly while the page fetch runs — layout persists.
Real-world example
Nested boundaries stream independent chunks:
app/shop/layout.tsx ← instant (cached shell)
├── loading.tsx ← segment skeleton
└── [slug]/page.tsx
├── ProductHeader ← streams first (~200ms)
└── Reviews ← streams later (~1.5s)Parallel route slots (@analytics, @team) each get their own loading.tsx and stream independently.
Interview:
loading.tsxworks for server async suspend, not just client-side fetch.
Progressive rendering
Definition
Progressive rendering means the user sees meaningful UI before all data resolves. The server sends HTML in ordered chunks; the browser paints each chunk as it arrives.
Chunk 1: <html>, layout shell, static header → immediate paint
Chunk 2: Suspense fallback skeletons → perceived progress
Chunk 3: resolved async segment (reviews) → content swap-in
Chunk 4: resolved async segment (recommendations) → below-fold fillClient-side, React hydrates streamed segments as they arrive. No full-page blank screen while slow queries run.
export default function ShopPage() {
return (
<main>
<ShopHeader /> {/* static — no Suspense */}
<Suspense fallback={<GridSkeleton />}>
<ProductGrid />
</Suspense>
<Suspense fallback={<BannerSkeleton />}>
<PromoBanner />
</Suspense>
</main>
);
}Interview: Progressive rendering improves perceived performance. Users interact with the shell while slow segments still load.
TTFB vs LCP
Definition
Streaming improves TTFB (time to first byte) by sending the shell early. LCP (largest contentful paint) still depends on which segment contains the hero element.
TTFB → first byte fast (shell + skeletons)
LCP → hero must be in an early segment to benefitPut the LCP element (hero image, product title) in the first Suspense boundary or outside Suspense entirely. Defer slow, below-the-fold content to later boundaries.
export default async function ProductPage(props: {
params: Promise<{ id: string }>;
}) {
const { id } = await props.params;
const product = await fetchProduct(id); // hero data — no Suspense wrapper
return (
<>
<img src={product.image} alt={product.name} /> {/* LCP element */}
<h1>{product.name}</h1>
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews id={id} /> {/* below fold — can stream later */}
</Suspense>
</>
);
}Interview: Streaming improves perceived performance (TTFB). LCP requires prioritizing the largest element in the first segment.
Avoiding waterfalls
Definition
Sequential await in one component creates a waterfall. Parallelize independent fetches:
// ❌ Waterfall — reviews wait for product
async function ProductPage({ id }: { id: string }) {
const product = await fetchProduct(id);
const reviews = await fetchReviews(id);
return <div>{product.name}{reviews.length} reviews</div>;
}
// ✅ Parallel — separate Suspense boundaries
export default function Page({ id }: { id: string }) {
return (
<>
<Suspense fallback={<HeaderSkeleton />}>
<ProductHeader id={id} />
</Suspense>
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews id={id} />
</Suspense>
</>
);
}Or use Promise.all when results must compose in one component:
async function ProductSummary({ id }: { id: string }) {
const [product, inventory] = await Promise.all([
fetchProduct(id),
fetchInventory(id),
]);
return <div>{product.name} — {inventory.count} in stock</div>;
}Waterfall vs parallel:
Waterfall → await A, then await B in same component → sequential
Parallel → separate Suspense boundaries or Promise.all → concurrentInterview: Multiple async components in separate Suspense boundaries fetch concurrently. Sequential await in one component is a waterfall.
Error handling with streaming
Definition
error.tsx catches errors in a route segment without breaking the entire streamed response:
// app/shop/[slug]/error.tsx
"use client";
export default function Error({
error,
reset,
}: {
error: Error;
reset: () => void;
}) {
return (
<div>
<p>Something went wrong: {error.message}</p>
<button onClick={reset}>Try again</button>
</div>
);
}Errors in one Suspense boundary do not prevent other boundaries from streaming successfully. Pair with error.tsx per segment for isolated failure domains.
Senior-level usage
Granular boundaries for a dashboard:
Layout (always visible)
├── Suspense + error.tsx → metrics panel
├── Suspense + error.tsx → chart panel
└── Suspense + error.tsx → activity feedOne failed panel does not blank the entire dashboard.
Interview: Streaming is resilient — one failed Suspense boundary does not abort the whole response.
Senior Interview Summary
Streaming SSR
Sends shell HTML first, streams Suspense replacements as async segments resolve. Powered by React 18 streaming under the hood.
Suspense placement
Wrap slow async Server Components — not the entire layout if nav is fast. Use
loading.tsxfor segment-level skeletons.
Progressive rendering
User sees shell + skeletons immediately; content chunks fill in as async work completes.
Best mental model
Blocking → "Wait for everything, then send."
Streaming → "Send shell now, fill in later."
TTFB → improves with streaming
LCP → depends on which segment has the heroPractical rule
Fast shell + slow data → Suspense around slow parts
LCP element → first segment or no Suspense
Independent fetches → separate Suspense boundaries
Sequential deps → Promise.all in one component
Below-the-fold content → defer to later boundaries
Per-segment errors → error.tsx per Suspense segment