Module A-5·26 min read

React 18/19 streaming internals, chunked Transfer-Encoding mechanics, selective hydration priority, hydration mismatch root causes, React.lazy vs dynamic() vs RSC boundary, and preventing flash before hydration.

JJS
Written by Jatin Jain Saraf · Senior Software Engineer

A-5 — Streaming SSR and the Suspense Architecture

Who this is for: Architects who want to understand the mechanics underneath PPR's dynamic holes and Next.js's streaming responses — how React 18/19 actually streams HTML from the server, how selective hydration works, how Suspense nesting affects the flush order, and where the failure modes are.


The Two Rendering Pipelines

React has two server rendering pipelines. Understanding which one Next.js uses in which situation is the prerequisite for everything in this module.

renderToString — the legacy pipeline. Synchronous. Renders the entire tree to a string and returns it. No streaming. No Suspense support. Still used by Pages Router in some configurations, but never used by App Router.

renderToPipeableStream / renderToReadableStream — the concurrent pipeline. Asynchronous. Streams output in chunks as async work completes. Full Suspense support. This is what App Router uses for every non-static response.

renderToPipeableStream is for Node.js environments (writable streams). renderToReadableStream is for the Web Streams API (edge runtime, Cloudflare Workers, Deno). Next.js uses whichever is appropriate for the runtime — you never call these directly, but understanding them explains the behaviour you observe.


How renderToPipeableStream Streams

When Next.js calls renderToPipeableStream, it passes two callbacks:

ts

The key insight: onShellReady fires as soon as the synchronous, non-suspended parts of the tree are rendered. Next.js starts streaming immediately at this point — it doesn't wait for async work. The HTTP response headers are sent, the browser starts receiving HTML, and data is still being fetched in the background.


Flush Points and Chunk Boundaries

The HTML that renderToPipeableStream sends isn't random chunks — it has a defined structure that corresponds to how React resolves Suspense boundaries.

text

$RC is React's built-in content-replacement function, injected into the HTML by the streaming runtime. It moves the resolved content from its hidden <div> into the Suspense placeholder, triggering React's client-side reconciliation for that subtree only.

Each Suspense boundary is an independent flush point. They resolve and flush independently — a slow database query for one boundary doesn't block a fast query for another.


Suspense Nesting and Resolution Order

Suspense boundaries can be nested, and the nesting order matters for what the user sees.

tsx

Resolution rules:

  • The outer boundary shows its fallback until both the outer shell AND all inner boundaries (or their fallbacks) are ready to render.
  • Inner boundaries show their own fallbacks independently.
  • A parent boundary that hasn't resolved yet holds all its children — inner fallbacks don't render until the parent shell is ready.

In the example above: while Header is resolving, the outer <PageSkeleton /> shows. Once Header resolves, the page shell shows with <SidebarSkeleton /> and <ContentSkeleton /> visible. Sidebar and MainContent then resolve independently.

The practical implication: wrap only the slow parts in Suspense. Wrapping the entire page in a single Suspense boundary (outer only) means the user sees nothing until the slowest data resolves. Granular Suspense gives granular progressive rendering.


Selective Hydration — How It Works

Hydration in the streaming model is not a single pass over the entire page. React 18+ introduced selective hydration: it hydrates Suspense boundaries as they arrive, independently.

Sign in to keep reading

The rest of this module is free — sign in with Google to unlock it and track your progress.

Sign in & Register

Discussion

0

Join the discussion

Loading comments...

© 2026 Jatin Jain Saraf (JJS). All rights reserved.