8.2 Streaming SSR and selective hydration
Plain server-side rendering has two waiting points: the server must finish the whole page before it can send any HTML, and the browser must hydrate the whole page before any button works. This lesson is about how React 18 removes both bottlenecks — it streams HTML piece by piece as data is ready, and hydrates piece by piece, prioritized by whatever the user touches first.
Two bottlenecks, two fixes
Lesson 8.1 treated SSR as a single block: render everything, send everything, hydrate everything. React 18 cuts that block along <Suspense> boundaries.
Each <Suspense> becomes two independent things at once: an independent streaming unit on the server (it can be sent later, after the rest), and an independent hydration unit on the client (it can be wired up later, or sooner, than its neighbors). The same boundary you already use for loading states is the lever for both.
Selective hydration: <Suspense> is the unit
Without any boundaries, the whole app is one hydration block — React must hydrate top-to-bottom before anything is interactive. Wrap pieces in <Suspense> and each piece becomes its own hydration unit that React can hydrate on its own schedule.
WITHOUT <Suspense>:
<App>
<Nav />
<Content /> ← user clicks here
<Sidebar />
</App>
Entire app = ONE hydration unit. Click must wait for everything.
WITH <Suspense>:
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ <Suspense> │ │ <Suspense> │ │ <Suspense> │
│ <Nav /> │ │ <Content /> │ │ <Sidebar /> │
│ (dehydrated)│ │ (dehydrated)│ │ (dehydrated)│
└─────────────┘ └─────────────┘ └─────────────┘
│
user clicks here
▼
Hydrate Content FIRST (SelectiveHydrationLane)
Others continue in background (OffscreenLane)
This is not automatic on an ordinary component. <Suspense> is required, for two reasons that come straight from the source:
- Dehydrated state only exists on Suspense fibers. The function that sets up a per-boundary hydration lane runs only for
SuspenseComponentfibers. Without a Suspense fiber there is nowhere to store that lane. - Event detection checks the tag. When an event arrives, React asks "is the nearest mounted fiber a
SuspenseComponent?" A non-Suspense-wrapped component is simply not recognized as a selectively-hydratable boundary.
Each boundary gets its own priority (a lane)
"Hydrate later" and "hydrate now" are expressed as lanes — the priority bits you met back in Module 3. When a Suspense boundary is set up for hydration, React picks a lane for it based on what the user is currently seeing.
If the real content is already visible, the boundary gets the very low-priority OffscreenLane — hydrate it whenever there's spare time. If only the fallback is visible, it gets DefaultHydrationLane. A user interaction can later boost a boundary far above either of these.
The real code
// mountDehydratedSuspenseComponent assigns a lane per boundary:
function mountDehydratedSuspenseComponent(workInProgress, suspenseInstance, renderLanes) {
if ((workInProgress.mode & ConcurrentMode) === NoMode) {
workInProgress.lanes = laneToLanes(SyncLane); // legacy mode
} else if (isSuspenseInstanceFallback(suspenseInstance)) {
workInProgress.lanes = laneToLanes(DefaultHydrationLane); // fallback visible
} else {
workInProgress.lanes = laneToLanes(OffscreenLane); // content visible, low-pri
}
return null;
}
Clicking a boundary that isn't ready yet
Here is the payoff. Say boundaries A, B, C are all still dehydrated and React is slowly hydrating them in the background. The user clicks inside B. React does not drop the click. It jumps the queue: hydrate B first, at high priority, then replay the click on the now-interactive B.
User clicks dehydrated Component B │ ├─ findInstanceBlockingEvent() → SuspenseComponent detected ├─ Event saved in queuedDiscreteEvents ├─ attemptSynchronousHydration(B fiber) → SelectiveHydrationLane ├─ B hydrated └─ Queued click replayed on now-interactive B A and C continue hydrating in background at OffscreenLane.
It happens in three steps, and you can see each one in the source below: detect that the event landed on a dehydrated boundary, queue the event while boosting that boundary's hydration, then replay the event once hydration finishes.
Lazy loading: the dehydrated boundary
The server can render a lazily-loaded component synchronously, because in the server process it just imports the module eagerly. The browser receives that HTML right away — but the matching JavaScript chunk may not have arrived yet. React's answer is to keep the server HTML on screen and mark the boundary dehydrated until the chunk is ready.
Fizz writes small comment markers into the HTML so the client can tell, per boundary, whether the content inside is ready, still pending, or a forced fallback:
The real code
SUSPENSE_START_DATA = "$" // ready content inside
SUSPENSE_PENDING_START_DATA = "$?" // content still pending (fallback visible)
SUSPENSE_FALLBACK_START_DATA= "$!" // fallback forced (e.g. error)
So the browser receives one of these two shapes, depending on whether the content was ready when the server flushed:
// Content resolved before flush:
<!--$--><div>ComponentA content</div><!--/$-->
// Content was pending at flush time:
<!--$?--><template id="B:0"></template><div>Loading...</div><!--/$?-->
On the client, when hydrateRoot() reaches a boundary whose chunk has not loaded, it does not tear down the server HTML. It records "hydrate this later" by storing a small dehydrated state on the Suspense fiber and creating a placeholder fiber that points at the existing server DOM:
The real code
// Suspense fiber during hydration (before chunk loads):
{
dehydrated: suspenseInstance, // reference to the comment node
treeContext: getSuspendedTreeContext(), // saved tree position for useId
retryLane: 536870912,
hydrationErrors: null
}
workInProgress.memoizedState = suspenseState;
// Creates placeholder fiber pointing to the SSR HTML:
createFiberFromDehydratedFragment(suspenseInstance);
The user keeps seeing real content the entire time. Notice treeContext is saved here — that's the saved tree position that later keeps useId stable when this boundary finally hydrates (more on that below).
Server: [Render ComponentA] ──► [Send HTML]
│
Browser: [Receive HTML] ─────────────────────────────────────►
[Show SSR content immediately — no JS required]
│
Main JS: ──────[Load]──[Execute]
│
▼
[hydrateRoot()]
│
└── ComponentA chunk not loaded yet
→ keep SSR HTML, create DehydratedFragment
ComponentA ──────────────────────[chunk loads]──[execute]
chunk: │
▼
[Hydrate that one Suspense boundary]
Streaming SSR with Fizz
Fizz is the name of React's server renderer (it lives in ReactFizzServer.js). It is a completely separate thing from the client reconciler — there are no fibers, no double-buffering, no commit phase, and no effects on the server. Fizz walks the element tree and produces HTML text.
CLIENT (react-dom) SERVER (react-dom/server — Fizz) ┌───────────────────────┐ ┌───────────────────────┐ │ Fiber tree │ │ No fibers │ │ Double-buffering │ │ Lightweight tasks │ │ Commit phase │ │ No commit phase │ │ Effects run │ │ No effects │ │ DOM mutations │ │ Outputs HTML text │ └───────────────────────┘ └───────────────────────┘
"Streaming" means Fizz does not wait for the slow parts. When a component inside <Suspense> throws a promise (it is waiting on data), Fizz flushes the fallback now and registers a callback to come back later. The moment that promise resolves, Fizz streams the real content plus a tiny inline script that swaps it into place.
The browser first receives the pending placeholder. Later, when the promise resolves, it receives the real segment (hidden) followed by a one-line script that moves it into the placeholder's spot:
// Placeholder injected immediately:
<!--$?--><template id="B:0"></template><div>Loading...</div><!--/$?-->
// When promise resolves, Fizz streams this inline script:
<div hidden id="S:1"><div>Actual Content</div></div>
<script>$RC("B:0", "S:1")</script>
// $RC = replaceContent — swaps the placeholder with the real segment
That single helper $RC is the whole streaming trick on the browser side: find the hidden real segment, drop it where the placeholder was, remove the fallback. No framework code needs to have loaded yet — it is plain inline JavaScript shipped with the HTML.
T=0ms renderSuspenseBoundary starts
└─ Content throws Promise → spawnNewSuspendedRenderTask()
promise.then(ping, ping)
└─ Queue fallback task
T=1ms Flush to stream
└─ Content still pending
Output: <!--$?--><template id="B:0"></template>
<div>Loading...</div>
Browser shows "Loading..."
T=500ms Promise resolves → ping() fires
└─ retryTask() renders actual content
└─ Stream: <script>$RC("B:0","S:1")</script>
Browser swaps to "Actual Content"
| Scenario | HTML emitted |
|---|---|
| Content completes before flush | Real content directly, no fallback |
| Content suspends (throws promise) | <!--$?--> placeholder + fallback, later inline <script> |
| Content errors | <!--$!-->, React on client retries |
Fork and useId: stable IDs across server and client
Streaming and selective hydration both rely on one promise: the server and client must agree on each component's identity, even though pieces arrive out of order. useId is where that promise gets tested.
useId does not use a global counter. A counter would never line up when boundaries stream in late or hydrate out of order. Instead it encodes the component's position in the tree as a binary path. Both sides walk the same tree and reach the same number — as long as the tree structure is the same on both sides.
A fork is the part that needs encoding: any parent with multiple children (an array, or a .map()). A single child changes nothing; siblings each need to record "I am child N of M" so their paths stay distinct.
Single child — NOT a fork (transparent to useId):
<Parent>
<Child />
</Parent>
Multiple children — IS a fork:
<Parent>
<Child /> sibling 0
<Child /> sibling 1
<Child /> sibling 2
</Parent>
Each branch: "I am child N of M" → encoded into the useId path
That binary path is exactly what gets compared. Both server and client run the same encoding, so the IDs agree precisely when the tree paths agree:
The real code
// ReactFizzTreeContext.js — every fork encodes position in binary:
// 00101 00010001011010101
// +--+--+ +--------+-------+
// Fork 5 of 20 Parent id
//
// Both server and client call the same pushTreeId() logic,
// so IDs match when — and only when — the tree paths match.
This is why a single-child wrapper does not matter. The client always creates an OffscreenComponent fiber inside every Suspense boundary; the server (Fizz) does not. But because that wrapper is a single child that does not call useId itself, it is a transparent indirection — it contributes zero bits to the path, so both sides still agree.
<> <>
<Indirection> <A />
<A /> <B />
</Indirection> </>
<B />
</>
These two trees produce THE SAME useId values — Indirection is transparent.
So when does useId actually mismatch? Only when the tree genuinely differs between server and client. Timing and wrappers are safe; structural differences are not:
| Factor | Causes mismatch? |
|---|---|
OffscreenComponent (single child, no useId) | No — transparent |
| Promise timing (early vs late resolution) | No — tree context is saved/restored per boundary |
Streaming (content arrives via <script>) | No — saved context travels with the content |
Conditional rendering (typeof window !== 'undefined') | Yes — different subtree on server vs client |
| Server renders fallback, client hydrates content | Yes — completely different tree paths |
| Different number of siblings anywhere in tree | Yes — fork count changes the encoded path |
When you do hit one, the two fixes are: keep the useId call on stable ground (above the Suspense boundary, passed in as a prop), and make sure the server and client read the same data state.
The real code
// 1. Move useId outside the Suspense boundary so it's on stable ground:
function Parent() {
const id = useId(); // stable — not inside any Suspense
return (
<Suspense fallback={<Loading />}>
<Child id={id} /> {/* pass as prop */}
</Suspense>
);
}
// 2. Ensure server and client see the same data state:
// Bad — timing varies:
const data = useSuspenseQuery(key);
// Better — same promise on both sides:
const data = use(prefetchedPromise);
See it in the source
The conceptual pieces above each map to a real function. These are the heavier blocks — open the ones you want to study line by line.
Selective hydration — detect, queue & boost, replay (the three steps)
Step 1 — detect the dehydrated boundary
function findInstanceBlockingEvent(domEventName, eventSystemFlags, targetContainer, nativeEvent) {
var nativeEventTarget = getEventTarget(nativeEvent);
var targetInst = getClosestInstanceFromNode(nativeEventTarget);
if (targetInst !== null) {
var nearestMounted = getNearestMountedFiber(targetInst);
if (nearestMounted.tag === SuspenseComponent) {
var instance = getSuspenseInstanceFromFiber(nearestMounted);
if (instance !== null) {
return instance; // signals: "this event is blocked on a dehydrated boundary"
}
}
}
return null;
}
Step 2 — queue the event and boost hydration
function dispatchEventWithEnableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay(...) {
var blockedOn = findInstanceBlockingEvent(domEventName, ...);
if (blockedOn === null) return; // not blocked, dispatch normally
if (queueIfContinuousEvent(blockedOn, domEventName, ...)) return; // queued
// Discrete event: synchronously boost hydration priority
while (blockedOn !== null) {
var fiber = getInstanceFromNode(blockedOn);
if (fiber !== null) {
attemptSynchronousHydration(fiber); // priority boost!
}
blockedOn = findInstanceBlockingEvent(...);
}
}
Step 3 — replay. Events are stored in named queues (queuedDiscreteEvents, queuedPointers, etc.) and replayed when attemptReplayContinuousQueuedEvent fires. The full list of replayable discrete events includes click, mousedown, keydown, pointerdown, touchstart, drop, change, submit, and more.
Lazy loading — the lazy initializer status machine
function lazyInitializer(payload) {
if (payload._status === -1) { // -1 = not started
var thenable = payload._result(); // kick off the import()
thenable.then(
(moduleObject) => { payload._status = 1; payload._result = moduleObject; },
(error) => { payload._status = 2; payload._result = error; }
);
payload._status = 0; // 0 = pending
payload._result = thenable;
}
if (payload._status === 1) return payload._result.default; // resolved
throw payload._result; // throw promise → Suspense catches
}
Fizz — renderSuspenseBoundary (try content, else queue a fallback)
// ReactFizzServer.js — renderSuspenseBoundary (simplified):
try {
renderNode(request, task, content, -1); // STEP 1: try to render children
contentRootSegment.status = COMPLETED;
if (newBoundary.pendingTasks === 0) {
newBoundary.status = COMPLETED;
return; // SUCCESS — no fallback ever needed, emit content directly
}
} catch (error) {
newBoundary.status = CLIENT_RENDERED; // ERROR — hand off to client
}
// If we reach here: content is pending (threw a promise)
// spawnNewSuspendedRenderTask() already registered: promise.then(ping, ping)
// STEP 2: queue a fallback render task
const suspendedFallbackTask = createRenderTask(request, null, fallback, ...);
request.pingedTasks.push(suspendedFallbackTask);
useId — the Forked flag, mountId, and saving/restoring tree context
Forked — an O(1) record that a fiber belongs to an array
var Forked = 1048576; // bit 20
// SET during reconciliation of array children:
function placeChild(newFiber, lastPlacedIndex, newIndex) {
newFiber.index = newIndex;
if (!shouldTrackSideEffects) {
// During hydration, useId needs to know about array membership.
newFiber.flags |= Forked; // only set for siblings in arrays!
}
}
// CHECK in beginWork:
function isForkedChild(workInProgress) {
return (workInProgress.flags & Forked) !== NoFlags;
}
// USE — only for pushTreeId (the only consumer of this flag):
if (getIsHydrating() && isForkedChild(workInProgress)) {
var slotIndex = workInProgress.index;
var numberOfForks = getForksAtLevel();
pushTreeId(workInProgress, numberOfForks, slotIndex);
}
mountId — capital R when hydrating, lowercase r when pure client-render
// Client-side mountId (react-dom):
function mountId() {
var hook = mountWorkInProgressHook();
var root = getWorkInProgressRoot();
var identifierPrefix = root.identifierPrefix;
var id;
if (getIsHydrating()) {
// Hydration path — tree-position-based, capital 'R':
id = ':' + identifierPrefix + 'R' + treeId + ':';
} else {
// Pure client-render path — sequential, lowercase 'r':
id = ':' + identifierPrefix + 'r' + globalClientId.toString(32) + ':';
}
hook.memoizedState = id;
return id;
}
The saved tree context travels with the boundary
// Server saves context when streaming a dehydrated boundary:
var suspenseState = {
dehydrated: suspenseInstance,
treeContext: getSuspendedTreeContext(), // ← saved!
retryLane: OffscreenLane
};
// Client restores it when hydrating that boundary:
function reenterHydrationStateFromDehydratedSuspenseInstance(
fiber, suspenseInstance, treeContext
) {
if (treeContext !== null) {
restoreSuspendedTreeContext(fiber, treeContext); // ← restored!
}
return true;
}