3.3 — Streaming & Suspense¶
Module 3 · Lesson 3 · 🟡 Intermediate · ~35 min
What you'll learn¶
- How streaming SSR converts "slowest query" TTFB into "fastest query" TTFB
- Where to place Suspense boundaries, and where not to
- Streaming without causing layout shift
loading.tsx, error boundaries, and what happens when a stream fails mid‑flight
The problem streaming solves¶
Without streaming, TTFB is bounded by your slowest data dependency, because nothing is sent until the whole tree renders.
Non-streaming SSR:
[----- product 180ms -----][-- price 120 --][----- reviews 260 -----][------ recs 310 ------]
▲ first byte at 870ms
Streaming SSR:
[----- product 180ms -----] ▶ shell flushed at 180ms, LCP image starts downloading
[-- price 120 --] ▶ flushed at 300ms
[----- reviews 260 -----] ▶ flushed at 440ms
[------ recs 310 ------] ▶ flushed at 490ms
▲ first byte at 180ms — the LCP image gets a 690ms head start
The critical insight: streaming doesn't make the page finish faster. It makes the browser start working sooner. The LCP image is discovered at 180 ms instead of 870 ms, and downloads while the server is still fetching reviews. That head start is the whole win, and it's usually worth 300–1,500 ms of LCP.
Basic usage¶
// app/p/[slug]/page.tsx
import { Suspense } from 'react';
export default async function ProductPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
// Awaited: needed for the shell. Keep this list minimal and fast.
const product = await getProduct(slug);
return (
<main>
{/* Everything above the fold renders immediately */}
<ProductGallery images={product.images} />
<h1>{product.name}</h1>
{/* Fast enough to await inline, but streaming it costs nothing and
protects the shell from a pricing-service slowdown */}
<Suspense fallback={<PriceSkeleton />}>
<PriceBlock productId={product.id} />
</Suspense>
<ProductDescription html={product.descriptionHtml} />
{/* Below the fold: always stream */}
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews productId={product.id} />
</Suspense>
<Suspense fallback={<RecommendationsSkeleton />}>
<Recommendations productId={product.id} />
</Suspense>
</main>
);
}
// Each of these is an async Server Component; it suspends while fetching.
async function Reviews({ productId }: { productId: string }) {
const reviews = await getReviews(productId); // 260ms — doesn't block the shell
return <ReviewList reviews={reviews} />;
}
Where to put boundaries¶
This is the whole skill. Wrong placement makes streaming useless or actively harmful.
| Put a boundary around | Don't put one around |
|---|---|
| Anything below the fold | The LCP element |
| Anything whose data source is slow or flaky (recommendations, reviews, personalization) | Fast data you need for the shell anyway |
| Anything from a third‑party API | Every single component ("boundary soup") |
| Personalized holes in an otherwise static page (PPR) | Something that will cause layout shift when it resolves |
// ❌ Boundary around the LCP element: the image can't be discovered until
// the pricing call resolves. You've made LCP worse.
<Suspense fallback={<HeroSkeleton />}>
<ProductHeroWithPrice productId={id} /> {/* contains the LCP image */}
</Suspense>
// ✅ Image outside the boundary, price inside it
<ProductHero images={product.images} name={product.name} /> {/* streams immediately */}
<Suspense fallback={<PriceSkeleton />}>
<PriceBlock productId={product.id} />
</Suspense>
// ❌ Boundary soup: 40 boundaries means 40 separate flushes, 40 fallback→content
// swaps, and a page that visibly assembles itself like a slot machine.
{products.map((p) => (
<Suspense key={p.id} fallback={<CardSkeleton />}>
<ProductCard product={p} />
</Suspense>
))}
// ✅ One boundary for the whole grid — it arrives as a unit
<Suspense fallback={<ProductGridSkeleton count={24} />}>
<ProductGrid categoryId={categoryId} />
</Suspense>
Rule of thumb: 2–5 boundaries per page. Enough to unblock the shell and isolate slow dependencies; few enough that the page doesn't flicker into existence in pieces.
Streaming without layout shift¶
A fallback that's a different size from its content is a CLS bug you built on purpose. Every skeleton must occupy exactly the space its content will occupy.
// ❌ Skeleton is 80px tall, real content is 340px → 0.15 CLS when it swaps
function ReviewsSkeleton() {
return <div className="h-20 animate-pulse bg-neutral-100" />;
}
// ✅ Same dimensions as the real thing
function ReviewsSkeleton() {
return (
<div className="min-h-[340px] space-y-4" aria-busy="true" aria-label="Loading reviews">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-[100px] rounded-lg bg-neutral-100 animate-pulse" />
))}
</div>
);
}
Practical techniques:
- Reserve by known shape. You know you render 3 review cards at 100px — build the skeleton from the same layout primitives as the real component.
min-heighton the container, sized to the typical (not minimum) content height.- Keep the section below the fold where possible. Shifts below the viewport still count toward CLS if they happen while that area is visible, so the safest place for a streamed section is genuinely off‑screen.
- Verify: throttle the network hard so the fallback is visible for seconds, then enable DevTools → Rendering → Layout Shift Regions and watch the swap.
A skeleton is not free UX either. If a section usually resolves in 80 ms, showing a skeleton creates a flash that feels worse than a brief blank. Consider awaiting inline for anything reliably under ~150 ms, and reserve skeletons for genuinely slow sections.
loading.tsx and route‑level streaming¶
app/p/[slug]/
├── page.tsx
├── loading.tsx ← automatic Suspense boundary around the whole page
└── error.tsx ← error boundary for the segment
// app/p/[slug]/loading.tsx — shown instantly on navigation while the page renders
export default function Loading() {
return <ProductPageSkeleton />;
}
loading.tsx wraps the entire route segment. It's most valuable for client‑side navigations,
where it gives instant feedback on tap instead of a frozen UI while the RSC payload fetches — which
is directly an INP improvement, since INP measures to the next paint.
But be careful: if loading.tsx covers the whole page, the shell is a skeleton and the LCP
element isn't in the initial HTML on a hard load. For a page whose LCP is an image, prefer granular
<Suspense> boundaries inside page.tsx so the hero is part of the first flush, and use
loading.tsx for the segments where a full skeleton is genuinely right (search results, account
pages).
Errors and partial failure¶
A streamed section that fails shouldn't take the page down.
// app/p/[slug]/page.tsx
import { ErrorBoundary } from 'react-error-boundary';
<ErrorBoundary fallback={<RecsUnavailable />}>
<Suspense fallback={<RecommendationsSkeleton />}>
<Recommendations productId={product.id} />
</Suspense>
</ErrorBoundary>
// Better for a commerce page: degrade silently rather than showing an error.
// A missing recommendations rail is invisible; an error box erodes trust.
async function Recommendations({ productId }: { productId: string }) {
try {
const recs = await getRecommendations(productId, { timeout: 800 });
if (!recs.length) return null;
return <RecommendationRail items={recs} />;
} catch (err) {
logError('recs_failed', { productId, err });
return null; // section simply doesn't appear
}
}
Always timeout streamed sections. Without a timeout, a hung recommendations service holds the HTTP response open. Users see a page that never finishes, browsers keep the connection alive, and your server's connection pool fills up.
// lib/fetch-with-timeout.ts
export async function fetchWithTimeout(
url: string,
{ timeout = 1000, ...init }: RequestInit & { timeout?: number } = {},
) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
try {
return await fetch(url, { ...init, signal: controller.signal });
} finally {
clearTimeout(timer);
}
}
Budget your timeouts against the section's value:
| Section | Timeout | On timeout |
|---|---|---|
| Price / stock | 1,200 ms | Show "check availability" + client retry — never a wrong price |
| Reviews | 800 ms | Omit section |
| Recommendations | 600 ms | Omit section |
| Recently viewed | 400 ms | Omit section |
Streaming gotchas¶
1. A CDN or proxy that buffers kills streaming. Some proxy configurations buffer the entire response before forwarding, converting streaming SSR back into blocking SSR. Verify end‑to‑end:
# Watch bytes arrive over time. You should see output in chunks, not all at once.
curl -N --raw -w '\n--- total: %{time_total}s, ttfb: %{time_starttransfer}s\n' \
https://www.auroramarket.com/p/wool-overshirt-navy | head -c 2000
If time_starttransfer ≈ time_total, you are not streaming in production regardless of what
your code does. Check for proxy_buffering off; in nginx, and for compression modules that buffer
to compress (some do — use a streaming‑friendly compression setting).
2. Streaming and HTTP status codes. Once the first byte is sent, the status is committed. An error in a streamed section can't turn a 200 into a 500. If a page must 404, resolve that before the first flush.
export default async function ProductPage({ params }) {
const { slug } = await params;
const product = await getProduct(slug);
if (!product) notFound(); // ✅ before any streaming begins
return /* … */;
}
3. <head> and metadata. generateMetadata runs before the shell flushes, so a slow metadata
fetch delays TTFB for the whole page. Keep it fast and cached — a slow generateMetadata is a
surprisingly common hidden TTFB cost.
// ❌ Blocks the whole page's first byte on a slow call
export async function generateMetadata({ params }) {
const { slug } = await params;
const seo = await getSeoDataFromSlowCms(slug); // 400ms — delays everything
return { title: seo.title };
}
// ✅ Derive from the same cached call the page uses (React `cache()` dedupes it)
export async function generateMetadata({ params }) {
const { slug } = await params;
const product = await getProduct(slug); // deduped with the page's call
return {
title: `${product.name} | Aurora Market`,
description: product.metaDescription,
};
}
4. Streaming doesn't help if the shell itself is slow. If getProduct takes 900 ms, your TTFB
is 900 ms no matter how many Suspense boundaries you add. Fix the shell query first
(7.1).
5. Nested boundaries resolve outside‑in. An inner boundary can't flush before its parent. Deep nesting serializes your streaming.
Aurora's PDP: before and after¶
// BEFORE: 830ms TTFB, LCP 4.6s
export default async function ProductPage({ params }) {
const { slug } = await params;
const product = await getProduct(slug); // 180ms
const price = await getPrice(product.id); // 120ms
const inventory = await getInventory(product.id); // 140ms
const reviews = await getReviews(product.id); // 260ms
const recs = await getRecs(product.id); // 310ms
const cms = await getCmsBlocks(product.id); // 190ms
return <FullProductPage {...{ product, price, inventory, reviews, recs, cms }} />;
}
// AFTER: 190ms TTFB, LCP 2.1s
export default async function ProductPage({ params }) {
const { slug } = await params;
const product = await getProduct(slug); // 180ms — the only blocking call
if (!product) notFound();
return (
<main>
<ProductGallery images={product.images} /> {/* LCP image in the first flush */}
<h1>{product.name}</h1>
<Suspense fallback={<PriceSkeleton />}>
<PriceAndStock productId={product.id} /> {/* parallel inside; ~140ms */}
</Suspense>
<ProductDescription html={product.descriptionHtml} />
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews productId={product.id} />
</Suspense>
<Suspense fallback={<RecsSkeleton />}>
<Recommendations productId={product.id} />
</Suspense>
</main>
);
}
// Inside the price hole: parallel, not sequential
async function PriceAndStock({ productId }: { productId: string }) {
const [price, inventory] = await Promise.all([
getPrice(productId),
getInventory(productId),
]);
return <PriceBlock price={price} inventory={inventory} />;
}
| Before | After | |
|---|---|---|
| TTFB | 830 ms | 190 ms |
| LCP image request starts | 1,450 ms | 340 ms |
| LCP | 4.6 s | 2.1 s |
| Total page complete | 4.9 s | 2.8 s |
Common mistakes¶
| Mistake | Cost |
|---|---|
| Suspense around the LCP element | Delays the thing you're trying to speed up |
| Boundary per list item | Flickering assembly; dozens of flushes |
| Skeletons with wrong dimensions | Self‑inflicted CLS |
| No timeouts on streamed sections | Hung responses, connection pool exhaustion |
| Proxy buffering in production | Streaming silently disabled |
Slow generateMetadata |
Delays the first byte for the whole page |
| Streaming a slow shell | The shell query is the real problem |
| Deeply nested boundaries | Serializes what should be parallel |
Lab 3.3 — Add streaming to your slowest page¶
- Measure the shell. Instrument each server data call with timing. Which one is on the
critical path for the LCP element? That's the only one you may
awaitinline. - Wrap the rest in Suspense boundaries — aim for 2–5.
- Build correct skeletons. Reuse the real component's layout primitives; verify with Layout Shift Regions on a throttled connection.
- Add timeouts to every streamed section, with a "degrade to nothing" fallback.
- Verify streaming in production with the
curl -Ntest. Iftime_starttransfer≈time_total, find the buffering proxy. - Measure TTFB, LCP, and CLS before/after.
Checklist¶
- Exactly one blocking data call for the shell (or a parallel group)
- LCP element outside any Suspense boundary
- 2–5 boundaries per page, not 40
- Every skeleton matches its content's dimensions
- Every streamed section has a timeout and degrades gracefully
-
notFound()/redirects resolved before the first flush -
generateMetadatauses cached/deduped calls - Streaming verified end‑to‑end in production, not just locally