Streaming SSR and selective hydration: HTML in waves
renderToPipeableStream sends the shell first; suspended subtrees stream later as hidden chunks plus inline scripts that swap them in. Hydration is per boundary and a click reprioritizes it, replaying the event. One slow await in the shell blocks everything.
A news site migrated its article pages to renderToPipeableStream and celebrated in staging: TTFB dropped from 1.9s to 210ms, the article body visible while comments streamed in behind it. Production told a different story — TTFB was worse than the old blocking renderToString for logged-in users, 2.4s at p99. The diff that did it was four lines: a personalization header reading the user’s subscription tier, awaited at the top of the layout component. The layout is above every Suspense boundary, which makes it part of the shell — and the stream does not start until the shell is complete. One slow session-store lookup (p99 900ms, plus a cold Redis replica that week) sat in front of every byte of HTML, including the static masthead. The team had rebuilt classic blocking SSR with extra steps, and the framework had done exactly what the boundaries told it to do. They moved the tier badge under a boundary with a null fallback, the shell became pure layout, and TTFB returned to 230ms — the badge popping in 800ms later, which nobody noticed. The streaming pipeline has one non-negotiable contract: the shell is the part you promised to render before anyone sees anything, and every await you put there is a tax on every user.
The pipeline: shell first, chunks as they resolve
renderToPipeableStream (Node; renderToReadableStream on edge runtimes) renders your tree on the server with Suspense boundaries as seams. Everything outside any boundary is the shell. The lifecycle:
- React renders until the shell is complete. Suspended subtrees do not block it — in their place, React emits the boundary’s fallback HTML.
onShellReadyfires; you pipe the response. The browser gets a complete, paintable page — layout, static content, skeletons — over one chunked HTTP response that stays open.- As each suspended subtree resolves on the server, React streams an additional chunk: the real HTML in a hidden
<div>, followed by an inline<script>that moves it into the fallback’s slot in the DOM. No client JS framework needed for the swap — it works before your bundle loads. - When all boundaries flush, the stream closes (
onAllReadyis the hook crawlers/static generation use instead — wait for everything, then send, trading TTFB for complete HTML).
The failure mode falls out of step 1: any data dependency outside a boundary is shell-blocking. It does not matter that the rest of the page is stream-ready; the first byte waits for the slowest await above the boundary line. This is the same placement discipline as the loading-UI lesson, now with TTFB attached — boundary placement decides not just what disappears together, but what the server may postpone.
// ❌ in the shell: every user's TTFB now includes this lookup
const tier = await session.getTier(userId); // p99 900ms
// ✅ under a boundary: shell streams immediately, badge streams later
<Suspense fallback={null}>
<TierBadge userId={userId} />
</Suspense>After migrating to renderToPipeableStream, a dashboard's TTFB is still 1.8s even though most widgets sit under Suspense boundaries. The trace shows the response's first byte waiting on a feature-flag service call. Where is the bug?
Selective hydration: per-boundary, interruptible, click-aware
On the client, hydrateRoot no longer hydrates the page as one synchronous monolith. Suspense boundaries divide hydration into independent units, and three properties follow:
- Hydration starts before everything arrives. The shell hydrates as soon as the bundle loads, even while later chunks are still streaming. Streamed-in boundaries hydrate as their code and content become available.
- Hydration is interruptible. Between boundaries, React yields to the browser — a long hydration no longer blocks input the way a single-pass
hydrate()did, where a 600ms hydration meant 600ms of dead buttons. - Interaction reprioritizes. If the user clicks inside a boundary that has not hydrated yet, React records the event, urgently hydrates that boundary first (jumping the queue ahead of earlier-in-tree boundaries), and then replays the click against the now-live handlers. The click is not lost and not handled by dead HTML — it is queued and delivered late. On a fast machine the gap is imperceptible; on a slow device the user notices a beat between tap and response, which is still strictly better than the pre-18 world where the tap silently did nothing.
The design consequence: boundaries are also hydration units. A page-wide boundary means click-anywhere hydrates everything; granular boundaries mean the comment box the user tapped hydrates in isolation, milliseconds instead of hundreds of them.
Hydration mismatches under streaming
Hydration compares the server HTML against the client’s first render and expects them identical. Streaming raises the stakes in two ways. First, the classic causes — Date.now(), new Date().toLocaleString(), Math.random(), user-agent branching, locale differences — now fire at different times per boundary: the server rendered the comments chunk 1.4s after the shell, so any time-derived value differs across boundaries as well as across server/client. Second, recovery is costlier than it looks: on a mismatch, React falls back to client-rendering the nearest Suspense boundary’s content from scratch, throwing away the streamed HTML for that boundary. The page does not crash, the console warns — and you silently pay double render cost plus a possible visual flicker for the region. A mismatch inside the shell (no boundary above it) is the expensive case: React re-creates a much larger region client-side. The discipline: anything nondeterministic renders identically (suppressHydrationWarning for a timestamp is a scalpel, not a lifestyle), or moves to an effect that runs only on the client, accepting the two-pass render for that node.
TTFB vs LCP: sizing the shell
Streaming gives you a dial, not a free lunch. Everything in the shell arrives at TTFB but delays it; everything behind a boundary improves TTFB but arrives in a later wave. The two Core Web Vitals in tension: TTFB (good under 800ms) rewards a thin shell; LCP (good under 2.5s) rewards having the largest contentful element in the first wave — if your hero image or article headline streams in as wave three, LCP is graded on wave three’s arrival. The senior heuristic: the shell contains layout plus the LCP element if and only if its data is reliably fast (single-digit-ms cache or no data at all); everything with a tail — personalization, recommendations, comments — goes behind boundaries. And measure the waves: a shell that is 90% of the page has merely renamed blocking SSR, while a shell that is an empty frame hands LCP to the second wave and loses what streaming bought.
During streaming, a user clicks 'reply' inside the comments section while its boundary is still un-hydrated. What does React 18+ actually do?
Failure modes worth rehearsing
- Slow await in the shell — the Hook’s incident: one session lookup above the boundary line gates TTFB for every user. Audit what is outside boundaries the way you audit what is in a hot path.
- LCP element behind a slow boundary — TTFB looks great on the dashboard while LCP regresses; the metrics disagree because the headline streams in wave two.
- Nondeterministic render in the shell — a hydration mismatch with no boundary to contain it client-renders half the page, doubling work on the user’s device exactly when it is busiest.
- Page-wide hydration unit — one boundary around everything means first click anywhere pays full hydration; granular boundaries make the tapped widget interactive in isolation.
- 01Trace an article page through renderToPipeableStream from request to fully interactive, naming what gates each milestone.
- 02What does a hydration mismatch cost under streaming SSR, what causes the streaming-specific ones, and what is the containment strategy?
Streaming SSR replaces the all-or-nothing HTML response with waves on one open connection. The shell — everything outside any Suspense boundary — renders first, and nothing is sent until it finishes: that is the contract that bit the news site, where one 900ms session lookup in the layout gated every byte for every user, rebuilding blocking SSR with extra steps. Suspended subtrees ship as fallback HTML inside the shell, then stream in later as hidden chunks paired with inline scripts that swap them into place — no bundle required for the swap, all on the same chunked response. On the client, boundaries become hydration units: hydrateRoot proceeds per boundary, yields between them, and treats interaction as a priority signal — a click inside a cold boundary is captured, that boundary jumps the hydration queue, and the click is replayed against live handlers instead of dying on dead HTML. Mismatches cost more than the warning suggests: React client-renders the nearest boundary’s content from scratch, so nondeterminism in the shell — where no boundary contains the damage — is the expensive variant, and streaming adds time-skew between chunks as a new source. The architecture question is sizing the shell against two metrics that pull in opposite directions: TTFB (under 800ms) wants the shell thin; LCP (under 2.5s) wants the largest element in the first wave. Layout plus fast-data LCP content in the shell, every tailed dependency behind a boundary — placement, again, is the entire design. Now when you migrate to renderToPipeableStream and TTFB does not improve, your first move is to audit what sits above your Suspense boundaries: one slow await there rebuilds the blocking SSR you were trying to escape.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.
Apply this
Put this lesson to work on a real build.