Skip to content

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:

  1. 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.
  2. min-height on the container, sized to the typical (not minimum) content height.
  3. 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.
  4. 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_starttransfertime_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

  1. 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 await inline.
  2. Wrap the rest in Suspense boundaries — aim for 2–5.
  3. Build correct skeletons. Reuse the real component's layout primitives; verify with Layout Shift Regions on a throttled connection.
  4. Add timeouts to every streamed section, with a "degrade to nothing" fallback.
  5. Verify streaming in production with the curl -N test. If time_starttransfertime_total, find the buffering proxy.
  6. 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
  • generateMetadata uses cached/deduped calls
  • Streaming verified end‑to‑end in production, not just locally

Next: 3.4 The Next.js caching layers