Siddhant Deval
Siddhant Deval
frontend5 min read

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.

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

TYPESCRIPT
// ❌ renderToString — the synchronous baseline
import { renderToString } from 'react-dom/server'

export async function GET(request: Request) {
  // All data must be fetched BEFORE rendering begins
  const [product, reviews, recommendations] = await Promise.all([
    getProduct(params.id),      // 40ms
    getReviews(params.id),      // 800ms  ← slowest determines TTFB
    getRecommendations(params.id), // 200ms
  ])

  // renderToString blocks until the full tree is rendered to a string
  const html = renderToString(
    <ProductPage product={product} reviews={reviews} recommendations={recommendations} />
  )

  // The browser receives nothing until all 800ms have elapsed
  return new Response(`<!DOCTYPE html>${html}`, {
    headers: { 'Content-Type': 'text/html' },
  })
}
// TTFB = max(40, 800, 200) + render time ≈ 820ms+

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:

TYPESCRIPT
import { renderToPipeableStream } from 'react-dom/server'
import { createServer } from 'http'

createServer((req, res) => {
  const { pipe, abort } = renderToPipeableStream(<App />, {

    // bootstrapScripts: the client bundle(s) to attach to the HTML
    // React needs these to hydrate the server-rendered HTML on the browser
    bootstrapScripts: ['/static/js/main.js'],

    // onShellReady: fires when the synchronous shell is ready
    // Use this for BROWSER requests — enables streaming
    onShellReady() {
      res.statusCode = 200
      res.setHeader('Content-Type', 'text/html')
      pipe(res) // Starts streaming immediately — Suspense fallbacks sent first
    },

    // onAllReady: fires when the ENTIRE tree has resolved (no pending Suspense)
    // Use this ONLY for static crawlers, PDF generators, or search engines
    // DO NOT use for browser requests — it defeats the entire purpose of streaming
    onAllReady() {
      // Only use here if you checked the user agent is a crawler
    },

    onShellError(error) {
      // The shell itself failed to render — send a fallback error page
      res.statusCode = 500
      res.setHeader('Content-Type', 'text/html')
      res.end('<h1>Something went wrong</h1>')
    },

    onError(error) {
      // A Suspense boundary or an async component threw
      // Logged server-side; the ErrorBoundary handles it client-side
      console.error(error)
    },
  })

  // Abort if the client disconnects after 10 seconds
  setTimeout(abort, 10_000)
}).listen(3000)
Crucial Requirement

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:

HTML
<!-- What the browser receives initially for a pending Suspense boundary -->
<!--$?-->
<template id="B:0"></template>
<div class="reviews-skeleton">Loading reviews...</div>
<!--/$-->

<!-- Later in the same stream, when Reviews resolves: -->
<div hidden id="S:0">
  <!-- The fully rendered Reviews HTML -->
  <article class="review">...</article>
  <article class="review">...</article>
</div>
<script>
  // React's inline resolver script
  $RC("B:0", "S:0") // Replace boundary B:0 with content S:0
</script>

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.

Architectural Note

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.

TYPESCRIPT
// useDeferredValue — marks a subtree as low-priority for hydration
// React will hydrate this subtree after urgent work is complete
function SearchPage({ query }) {
  const deferredQuery = useDeferredValue(query) // Low-priority

  return (
    <div>
      <SearchInput value={query} /> {/* Hydrates immediately (urgent) */}
      <Suspense fallback={<ResultsSkeleton />}>
        <SearchResults query={deferredQuery} /> {/* Hydrates when the scheduler is free */}
      </Suspense>
    </div>
  )
}

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.

TYPESCRIPT
// Each interactive section wrapped in its own Suspense boundary
// enables selective hydration prioritization
export default function ArticlePage() {
  return (
    <article>
      <ArticleHeader />  {/* Hydrated first — synchronous shell */}

      <Suspense fallback={<CommentsSkeleton />}>
        <CommentsSection />  {/* Boundary A — can be prioritized if user clicks here */}
      </Suspense>

      <Suspense fallback={<RelatedSkeleton />}>
        <RelatedArticles />  {/* Boundary B — lower priority */}
      </Suspense>
    </article>
  )
}

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>.

Crucial Requirement

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:

TYPESCRIPT
// ❌ Cause 1: timestamps and dates rendered server-side
function PublishedAt({ date }: { date: Date }) {
  // new Date() formats differently in Node.js (server) vs. browser (different timezone)
  return <time>{date.toLocaleDateString()}</time>
}

// ✅ Fix: use a stable, locale-independent format server-side; format client-side with useEffect
function PublishedAt({ date }: { date: Date }) {
  const [formatted, setFormatted] = useState(date.toISOString().slice(0, 10))
  useEffect(() => {
    setFormatted(date.toLocaleDateString()) // Format client-side after hydration
  }, [date])
  return <time suppressHydrationWarning>{formatted}</time>
}

// ❌ Cause 2: Math.random() or crypto.randomUUID() in component render
function TooltipTarget() {
  const id = Math.random().toString(36) // Different every render — guaranteed mismatch
  return <span aria-describedby={id}>Hover me</span>
}

// ✅ Fix: stable IDs from useId() — SSR-safe, hydration-safe
function TooltipTarget() {
  const id = useId() // React 18+ — generates a stable ID consistent across SSR and hydration
  return <span aria-describedby={id}>Hover me</span>
}

// ❌ Cause 3: browser API calls in component render
function ScreenWidth() {
  return <span>Width: {window.innerWidth}px</span> // window is undefined on server
}

// ✅ Fix: check environment or use useEffect for browser-only values
function ScreenWidth() {
  const [width, setWidth] = useState<number | null>(null)
  useEffect(() => { setWidth(window.innerWidth) }, [])
  return <span>{width !== null ? `Width: ${width}px` : 'Measuring...'}</span>
}
Performance / Safety Warning

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:

  1. A component has an async data dependency that could be slow (>50ms)
  2. A component is independently interactive and should be prioritizable by selective hydration
  3. 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 →

Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#React#Streaming SSR#Selective Hydration#Suspense#Performance#TTFB
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.