Skip to content
advanced Phase 8 · Rendering Strategies: SSR, SSG, and ISR

Streaming with Suspense Boundaries

Flush fast content first and slower sections after.

45m
0 problems
Topic Progress 0%

The Two-Second Hole: Why loading.tsx Changes the First Paint

A dashboard page awaits its heaviest query — recommendations from a slow service, two seconds — and only then returns JSX. The visitor stares at nothing for two full seconds, even though the nav, the sidebar, and the account summary were ready in 30 ms. The page had everything except a way to show it. Streaming server rendering fixes this by flushing the parts that are ready immediately — the shell — and filling the slow sections in as their data resolves.

The mechanism is React's Suspense, applied server-side. When React renders on the server and hits a Suspense boundary whose subtree is not ready, it writes the fallback into the HTML stream and moves on; when the awaited data lands, the real content is flushed downstream in a later chunk. The browser paints the shell from the first chunk and progressively reveals sections — no client-side framework required for any of it, because the swapping happens through out-of-order inline <script> calls Next emits into the stream.

The App Router wires the first boundary for you. A loading.tsx in a segment wraps that segment's page in Suspense automatically:

// app/dashboard/loading.tsx — becomes <Suspense fallback={<DashboardSkeleton />}>
export default function DashboardSkeleton() {
  return <Spinner label="Loading your dashboard…" />;
}

// app/dashboard/page.tsx — unchanged, still awaits its slow query
export default async function DashboardPage() {
  const [summary, recs] = await Promise.all([getSummary(), getRecommendations()]);
  return (
    <>
      <SummaryCard data={summary} />
      <Recommendations items={recs} />
    </>
  );
}

Read the timing honestly, because loading.tsx gives you half the win. With the automatic boundary, the whole page — summary and recommendations — is inside one Suspense: the shell flushes at first-paint time (whatever the layout contributes), then the page's content arrives only when the slowest await in it completes. The two-second recommendations query still holds back the 30 ms summary. loading.tsx guarantees the visitor is not staring at a blank document; it does not prioritize the fast content over the slow.

That distinction — shell now, everything else together versus shell now, each part as soon as it is ready — is the entire subject of the next chapter, and it is where most teams' streaming setups stall. One prerequisite to check before assuming streaming applies: the route must be dynamically rendered. A fully prerendered static page is complete HTML; there is nothing to stream. Streaming is the serving mode of dynamic rendering (the two features are the App Router's default pairing for ƒ routes), and its benefits apply per request. Self-hosted, your Node server pipes the chunks as React produces them; on platforms with response compression in front, confirm the flush behavior from Streaming Responses — a buffering proxy collapses your beautifully streamed HTML back into one burst.

For interviews, state the pipeline in one breath: dynamic APIs make the route request-rendered → React renders the tree, flushing the shell around Suspense boundaries → unresolved boundaries emit fallbacks and complete later out of order → the client hydrates each section as its chunk lands, with loading.tsx providing the initial boundary automatically.

Boundary Craft: Isolating the Slow Fetch Without Faking Ten Skeletons

Here is the shape that actually ships fast pages: each Suspense boundary wraps exactly the component whose data is slow — not the page, not the layout, and not every leaf. Widen it to the page (what loading.tsx does) and the slowest query gates everything; shrink it to every leaf and your HTML fills with a dozen spinners that each cost a chunk, a script tag, and a hydration pass.

The worked example — a product page where reviews are slow and recommendations are medium:

// app/product/[slug]/page.tsx
import { Suspense } from "react";

export default async function ProductPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await props.params;
  const product = await getProduct(slug);        // fast, awaited inline
  return (
    <main>
      <ProductHero product={product} />          {/* already in the shell */}
      <Suspense fallback={<ReviewsSkeleton />}>
        <Reviews slug={slug} />                  {/* slow: 1800 ms */}
      </Suspense>
      <Suspense fallback={<RecsSkeleton />}>
        <Recommendations slug={slug} />          {/* medium: 600 ms */}
      </Suspense>
    </main>
  );
}

async function Reviews({ slug }: { slug: string }) {
  const reviews = await getReviews(slug);        // isolated: only this boundary waits
  return <ReviewList reviews={reviews} />;
}

Trace the stream: the hero flushes with the first chunk (the inline awaits resolve before anything streams), recommendations land at ~600 ms, reviews at ~1800 ms — each section's arrival time is its own fetch, not the page's maximum. Two details make this work in the App Router. Server Components can be async, so the boundary's subtree simply awaits its own data; and boundaries must wrap component boundaries, not conditional expressions — <Suspense> around an await you already ran in the parent does nothing, because the parent already blocked.

Where should the await live? The rule: fast, sequential, layout-critical data stays inline in the page; slow or independent data moves down behind a boundary. Awaiting a product row before the hero is right — the page is meaningless without it. Awaiting reviews inline is wrong — the page was meaningful without them, and you just converted 1800 ms of background work into 1800 ms of visible blankness. This inverts the Pages Router habit of one big getServerSideProps waterfall: parallelize (Promise.all) what the shell needs, and demote the rest into boundary-owned subtrees that fetch on their own.

Boundaries nest, and nesting is how you serve both concerns — a page-level loading.tsx as the catch-all fallback plus narrower inner boundaries for the slow islands. Note the interaction: an inner Suspense boundary that resolves while its outer boundary is still pending gets de-duplicated by React — resolved content is not flashed as a fallback first. Your fallback budget can therefore stay small: two or three coarse skeletons that visually reserve space, not one per component.

Client components join the same system through the use() hook, which lets a client leaf read a promise passed from the server — suspending inside its own boundary instead of triggering a client-side waterfall:

"use client";
import { use } from "react";

export function Reviews({ reviewsPromise }: { reviewsPromise: Promise<Review[]> }) {
  const reviews = use(reviewsPromise);      // suspends within the enclosing boundary
  return <ReviewList reviews={reviews} />;
}

The trap to avoid in either world: a boundary whose fallback is much smaller than its content is a layout-shift generator. The stream reserves no space for content it has not seen; when the 400 px review list replaces a 40 px spinner, everything below jumps — a measurable Cumulative Layout Shift penalty dressed up as a nice loading state. Size your skeletons to the final content's footprint; that single habit separates streaming that improves vitals from streaming that trades one metric for another.

Measuring the Win: LCP, Layout Shift, and Error Semantics in a Streamed Tree

Streaming is sold on Largest Contentful Paint, and the mechanism holds up: the LCP candidate — the hero, the headline, the main card — usually lives in the shell or the first resolved boundary, so it starts downloading in the first chunk instead of waiting for the page's slowest query. On a page where the hero renders in 80 ms but the data waterfall totaled 2100 ms, streaming moves LCP from ~2.1 s to under 300 ms without making a single query faster. That is the number to measure on your own route (Lighthouse or the web-vitals library) before and after adding boundaries, and the number an interviewer wants to hear you have collected.

The metric ledger has three other entries:

Metric Effect of streaming What to watch
LCP improves — hero ships in the first chunk keep the LCP element out of slow boundaries
CLS risks regression — fallbacks swap into place later size skeletons to final content; never let rows reflow
INP/hydration slightly more work — several sections hydrate as chunks land count boundaries; 30 tiny ones cost more than 3 coarse ones
TTFB unchanged from dynamic rendering — still waits on the shell's data keep the shell's awaits fast

The CLS row is the honest cost of the feature, and it is a design bug, not a framework limitation: the stream cannot reserve space for content whose size it never declared. Fixed-height skeletons, min-height on containers, and skeletons that mirror the final layout are the toolkit. The hydration row is the subtle one — every resolved boundary emits a chunk the client must process, so an infinitely granular boundary design taxes the main thread on low-end phones; this is why the guidance "two or three coarse boundaries per page" beats "one per component" on both axes.

Errors deserve explicit treatment, because streaming changes their blast radius. A throw inside a boundary that already flushed cannot repaint the page as an error screen — the shell is gone. React handles this by replacing the boundary's subtree in place: a sibling error.tsx captures the failure and swaps its UI into the hole where the content would have gone, leaving the rest of the page intact. That is a feature of the model: a failed reviews fetch degrades one section, not the route. The design consequence: give each slow boundary its own error boundary, or one throwing subtree takes down everything inside its nearest ancestor handler. And remember the transport layer from the route-handler world: an exception that escapes before the shell flushes fails the whole response; after the shell, only its boundary.

SEO and bots, briefly, because it comes up: crawlers that read raw HTML receive the complete streamed document — the stream is a transport detail, and by the time a bot parses the response, all chunks are in the file. The nuance is timing-sensitive bots and previews that fetch and parse quickly, plus any content you stream after client-side transitions, which no plain HTML fetcher sees at all. Critical content — headings, product data, the JSON-LD of JSON-LD Structured Data — belongs in the shell.

The finishing move for a streaming page is naming its parts: loading.tsx for the whole segment, error.tsx per risky boundary, and notFound() for missing records inside the boundary that owns the lookup. With those in place the route has a defined behavior for every arrival time — fast data, slow data, failed data — which is the practical definition of a production-grade streaming setup, and the story to tell when an interviewer asks what "good" looks like beyond the happy path.

Quiz

1. A page awaits a fast summary (30 ms) and a slow recommendations fetch (2000 ms) inline, with only a segment loading.tsx present. When does the summary become visible?

Question 1 options

2. Where must an await live for streaming to help?

Question 2 options

3. A streamed page shows a 40 px spinner where a 400 px review list will appear. What is the likely Core Web Vitals consequence?

Question 3 options

4. getReviews() throws 1.5 seconds into a streamed response, inside its own Suspense boundary with a sibling error.tsx. What does the user see?

Question 4 options

Flashcards

Question

What does loading.tsx do in a route, and what does it NOT guarantee?

Answer

It auto-wraps the segment's page in a Suspense boundary whose fallback is that file's UI, so the shell flushes without waiting for the page's data. It does not prioritize within the page: every await inside that single boundary still gates its content on the slowest one.

Question

The boundary-granularity rule for streaming fast pages.

Answer

Wrap exactly the slow, independent subtree in its own Suspense — keep fast sequential data awaited inline in the shell. Page-wide boundaries gate on the slowest fetch; per-leaf boundaries multiply chunks, fallbacks, and hydration passes.

Question

How does streaming change a page's metrics, and which one is the trap?

Answer

LCP improves (hero ships in the first chunk), TTFB is unchanged from dynamic rendering, hydration cost rises slightly per boundary. The trap is CLS: undersized fallbacks reflow the page when real content arrives — size skeletons to final content.

Question

What happens when data throws after the shell has already streamed?

Answer

The error is contained to the throwing subtree: React swaps the nearest error boundary's UI into that slot in place; the rest of the page stays. Before the shell flushes, the whole response fails — so give each slow boundary its own error.tsx.

Question

Does streaming apply to a fully static page, and what do HTML-only bots see?

Answer

No — a prerendered page is complete HTML with nothing left to stream; streaming is the serving mode of dynamically rendered routes. Crawlers receive the complete document once the response ends (chunks are transport detail), so keep critical content and JSON-LD in the shell for fast fetchers.

Revision Notes

Key Takeaways

  • 1. Streaming flushes the shell first and fills Suspense-wrapped sections as their data resolves; loading.tsx is the automatic page-wide boundary
  • 2. Per-section arrival requires per-section boundaries: await fast data in the page, demote slow fetches into Suspense subtrees
  • 3. The LCP gain is real and measurable; the CLS penalty from undersized skeletons is optional — design fallbacks to reserve space
  • 4. Errors after the shell are contained to their boundary's slot via error.tsx; give every risky boundary its own
  • 5. Streaming is the serving mode of dynamic rendering — prerendered pages have nothing to stream, and buffering proxies can flatten the chunks

Interview Tips

  • • "How does streaming improve LCP?" — the LCP element ships in the first flushed chunk instead of waiting on the page's slowest await; quantify with a before/after from your own route, and note TTFB is unchanged
  • • "loading.tsx or explicit Suspense?" — loading.tsx as the coarse safety net, explicit narrow boundaries for slow independent islands; explain that one page-wide boundary still gates content on the slowest fetch
  • • "What can go wrong with streaming?" — CLS from undersized fallbacks, hydration overhead from over-granular boundaries, error containment (pre- vs post-shell), and infrastructure buffering that collapses chunks
  • • "Where does an error go in a streamed page?" — the shell keeps rendering; the nearest error boundary's UI replaces the failed subtree in place; before the shell flushes, the whole response fails

Cheat Sheet

Streaming with Suspense Boundaries

Pipeline: dynamic route → React flushes the shell around Suspense boundaries → unresolved boundaries emit fallbacks → real content streams in out of order → client hydrates each section as its chunk lands. Server-driven swap, no client framework needed.

loading.tsx = one page-wide boundary:

app/dashboard/loading.tsx   // auto-wraps the page: shell now, ALL page content at the slowest await

For per-section arrival, demote slow work into explicit boundaries:

const product = await getProduct(slug);        // shell
<Suspense fallback={<ReviewsSkeleton />}><Reviews slug={slug} /></Suspense>   // 1800 ms
<Suspense fallback={<RecsSkeleton />}><Recommendations slug={slug} /></Suspense> // 600 ms

Rule: fast sequential data awaited in the page; slow/independent data awaited inside its boundary. A parent that already awaited blocks the shell — boundaries wrap un-started subtrees.

Vitals ledger: LCP up (hero in first chunk) · TTFB unchanged (shell still waits) · CLS risk from undersized fallbacks (size skeletons to content!) · hydration cost per boundary (2–3 coarse beats per-leaf).

Errors: post-shell throws replace the nearest error.tsx subtree in place; pre-shell throws fail the response. One error boundary per risky section.

Bots: raw HTML contains all chunks; put critical content and JSON-LD in the shell.

Pairings: streaming ⇔ dynamic rendering (static pages have nothing to stream); self-host or verify proxy flush behavior if chunks arrive as one burst.

Continue Learning

Before You Start

Next Topics

Related Reading