Topics
v5.0.0
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.

ModeTTFBUser seesTrade-off
Blocking SSRWaits for all dataComplete page at onceSlow DB = slow first byte
Streaming SSRShell sent immediatelyLayout + skeletons, then chunksLCP 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 progressively

Interview: 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.tsx works 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 fill

Client-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 benefit

Put 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 → concurrent

Interview: 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 feed

One 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.tsx for 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 hero

Practical 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

Last updated on