Streaming SSR & Selective Hydration: How React Fills the Shell
Streaming SSR does not hydrate all at once — React prioritizes which Suspense boundaries to hydrate first based on user interaction, and understanding this scheduler contract is what separates intentional performance architecture from accidental latency.
Modern Web Rendering & Performance
Streaming SSR & Selective Hydration: How React Fills the Shell
Most React applications block on the slowest thing. When the server renders a page with renderToString, it waits for every component in the tree to resolve before sending a single byte. The user's browser sits idle, staring at a blank screen, while the server waits for the comments database query that takes 800ms. The header — which was ready in 12ms — waits for the comments. The above-the-fold content — ready in 40ms — waits for the comments. Every millisecond the server waits is a millisecond the browser cannot begin parsing, building the DOM, or loading subresources.
renderToPipeableStream breaks this contract. The server sends the HTML shell the instant it is ready — headers, navigation, the LCP hero, the page skeleton. Slow data dependencies suspend their Suspense boundaries, which render as their fallbacks. When those dependencies resolve, their content arrives as a subsequent chunk in the same HTTP stream, and React replaces the fallback with the real content — without a second round-trip, without a page reload, without rehydrating the entire page.
1. The Broken Pattern: renderToString Serializes Everything First
The problem compounds with the component tree depth. Any component that fetches data inside itself cannot start fetching until its parent renders — creating sequential waterfalls where Promise.all at the root is the only way to avoid them, which in turn requires the root to know about every nested data dependency.
2. renderToPipeableStream: The API in Depth
renderToPipeableStream returns a stream that can be piped to a Node.js HTTP response. The critical choice is which callback to use:
onShellReady vs. onAllReady is the single most consequential implementation decision in streaming SSR. Using onAllReady for browser requests turns streaming SSR into blocking SSR — the browser receives nothing until every await in the tree has resolved. Always use onShellReady for real browser requests.
3. How Chunks Are Delivered: The Inline Script Contract
When a <Suspense> boundary resolves, React does not replace the fallback by re-rendering the entire page. Instead, it injects a small <script> block and a <template> element into the existing HTML stream:
The $RC function is defined by React's bootstrap script. It moves the hidden <div> content into the <template> placeholder position and removes the fallback — a DOM swap, not a re-render. This is why out-of-order chunk arrival works correctly: the inline script always references the specific boundary ID it resolves, independent of stream ordering.
This mechanism is why bootstrapScripts is mandatory. Without the React client bundle, the $RC function is not defined, the inline scripts throw, and Suspense boundaries stay as their fallback forever. The streaming benefits are completely lost.
4. Progressive vs. Selective Hydration
These two terms describe different behaviors, and conflating them leads to incorrect <Suspense> placement decisions.
4.1 Progressive Hydration
Progressive hydration is the scheduling of hydration across multiple Suspense boundaries — React does not hydrate all boundaries in a single synchronous pass. Instead, it yields between boundaries, allowing the browser to handle other work (input events, rendering) between hydration tasks.
4.2 Selective Hydration
Selective hydration is React's ability to prioritize hydrating a specific Suspense boundary because the user is interacting with it — regardless of its position in the tree or its resolution order.
If a user clicks on <CommentsSection> while <RelatedArticles> is still hydrating, React interrupts <RelatedArticles> hydration, hydrates <CommentsSection> first (because the user's click requires it to be interactive), then returns to <RelatedArticles>.
Selective hydration requires each independently interactive component to be in its own <Suspense> boundary. A single <Suspense> wrapping the entire page body means React cannot distinguish which boundary the user is interacting with — and cannot prioritize.
5. Hydration Mismatch Errors
A hydration mismatch occurs when the HTML string sent by the server does not match what React would render on the client with the same props. React detects this during reconciliation and logs a warning — or in severe cases, discards the server HTML and re-renders from scratch, causing a flash of content.
Most common causes:
suppressHydrationWarning tells React to accept the mismatch for a specific element without throwing. Use it only for elements where you know the divergence is intentional (e.g., a timestamp that is formatted differently on server vs. client). Do not use it as a blanket fix — it hides real bugs.
6. <Suspense> Composition Strategy
The number of <Suspense> boundaries is a real engineering tradeoff:
| Granularity | Advantages | Disadvantages |
|---|---|---|
Page-level (one <Suspense> around body) |
Simple to reason about | No streaming benefit; selective hydration impossible; slowest component blocks everything |
| Section-level (one per major section) | Moderate streaming; sections hydrate independently | Cross-section interaction still requires multiple boundaries to be hydrated |
| Component-level (one per async leaf) | Maximum streaming granularity; selective hydration works precisely | More inline <script> blocks in HTML; slightly more complex tree |
Heuristic: Add a <Suspense> boundary at every place where:
- A component has an async data dependency that could be slow (>50ms)
- A component is independently interactive and should be prioritizable by selective hydration
- A component below the fold whose slow resolution should not block above-the-fold content
7. Measuring the Streaming Benefit
Streaming SSR improves specific metrics. Understanding which metrics it affects prevents misinformed optimization decisions:
| Metric | Does Streaming Improve It? | Why |
|---|---|---|
| TTFB | ✅ Yes — significantly | Shell is sent as soon as it renders, not when all data resolves |
| FCP | ✅ Yes — for shell content | Shell HTML reaches the browser sooner; CSS and fonts can load in parallel |
| LCP | ✅ Only if LCP element is in the shell | If LCP element is inside a <Suspense> boundary, it arrives in a later chunk |
| TTI | ✅ Yes — via selective hydration | React hydrates the boundary the user is interacting with first |
| CLS | ⚠️ Risk if Suspense fallbacks have mismatched dimensions | Fallback → resolved content swap can trigger layout shift if dimensions differ |
Summary
| Concept | Rule |
|---|---|
onShellReady vs. onAllReady |
Always use onShellReady for browser requests. onAllReady waits for the full tree — equivalent to renderToString for streaming purposes. |
bootstrapScripts |
Mandatory. Without it, the $RC boundary resolver scripts are undefined and Suspense boundaries never swap from fallback to content. |
| Inline script injection | React injects <script>$RC("B:id","S:id")</script> per resolved boundary. This is a DOM swap, not a re-render. Out-of-order arrival is handled correctly. |
| Progressive vs. selective | Progressive = React yields between boundaries during hydration. Selective = React prioritizes the boundary the user is interacting with. Both require each interactive unit in its own <Suspense>. |
| LCP and streaming | The LCP element must be in the synchronous shell — not inside any <Suspense> boundary. If it is in a boundary, it arrives in a later chunk and LCP is set by stream latency, not shell render time. |
What's Next
In Part 5, we step outside React entirely and look at Islands Architecture — the approach that ships zero JavaScript for static content, with isolated interactive islands that each hydrate independently using Astro, Qwik, or any cross-framework host. Part 5: Islands Architecture →
This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.