Streaming SSR and Selective Hydration with Code Splitting
Symptom: a dashboard route renders correctly and looks complete within 400 ms of the response starting to stream, but a user who clicks an interactive filter control in the sidebar — visually simple, low in the DOM tree relative to a large data table above it — has to wait for the entire page above it to finish hydrating before the click registers. A DevTools Performance trace of the hydration phase looks like a strict staircase:
0ms ── Shell HTML received, painted
120ms ── Hydrate: <Header> (attaches listeners)
340ms ── Hydrate: <DataTable> (attaches listeners, 220ms — large lazy chunk)
410ms ── Hydrate: <SidebarFilters> (attaches listeners — the control the user clicked at 145ms)
The click at 145 ms sits inside that gap. It is silently queued by React and only replayed once SidebarFilters finishes hydrating at 410 ms — a 265 ms perceived-lag on a control that itself takes single-digit milliseconds to hydrate. Multiply this across every route with more than one lazy-loaded widget and the aggregate cost shows up directly in Interaction to Next Paint (INP) field data, typically adding 400–900 ms across the affected session.
Root cause: one Suspense boundary means one hydration unit
This is a direct extension of the streaming and hydration concerns raised in the parent cluster, code splitting for SSR and React Server Components. React 18’s streaming renderers — renderToPipeableStream and renderToReadableStream — can flush HTML for a Suspense boundary out of document order as soon as its content resolves, and hydrateRoot can, in principle, hydrate boundaries in the same flexible order, prioritizing whichever one the user is actively interacting with. That flexibility only exists between Suspense boundaries. Everything inside a single boundary hydrates as one atomic unit, in the order React’s commit phase processes it. If DataTable and SidebarFilters are both inside one top-level <Suspense> around the whole page body, React has no boundary to split priority across — it hydrates the combined subtree as a single block, and a large lazy-loaded table sitting earlier in that block blocks a small, unrelated control sitting later in it.
This is the flip side of the SSR-and-RSC boundary problem covered in the companion guide using dynamic import() in React Server Components: that page is about drawing the right Server/Client Component boundary; this one is about drawing the right Suspense boundary once you’re inside a Client Component tree that’s already hydrating. Both are boundary-placement problems, just at different layers of the same rendering pipeline. The underlying chunking strategy is unchanged from the parent route-based code splitting pillar — this is purely about how many independently schedulable units the hydration phase sees.
The diagram below contrasts the single-boundary case, where everything hydrates as one blocking unit, against a per-widget boundary layout, where the boundary the user actually interacts with can hydrate ahead of a slower, unrelated sibling.
Exact fix: split one boundary into several
Before (one boundary, one blocking unit):
// app/dashboard/page.tsx — single Suspense boundary wrapping the whole body
import { Suspense, lazy } from 'react';
const DataTable = lazy(() => import('./DataTable'));
const SidebarFilters = lazy(() => import('./SidebarFilters'));
export default function DashboardPage() {
return (
<Suspense fallback={<div className="page-skeleton" aria-busy="true" />}>
<Header />
<DataTable />
<SidebarFilters />
</Suspense>
);
}After (independent boundaries, independent hydration priority):
// app/dashboard/page.tsx — per-widget Suspense boundaries
import { Suspense, lazy } from 'react';
const DataTable = lazy(() => import('./DataTable'));
const SidebarFilters = lazy(() => import('./SidebarFilters'));
export default function DashboardPage() {
return (
<>
<Header />
<Suspense fallback={<div className="table-skeleton" aria-busy="true" style={{ height: 480 }} />}>
<DataTable />
</Suspense>
<Suspense fallback={<div className="filters-skeleton" aria-busy="true" style={{ height: 220 }} />}>
<SidebarFilters />
</Suspense>
</>
);
}On the server, pairing this with renderToPipeableStream lets each boundary’s real content flush independently as soon as its chunk resolves, rather than waiting for the slowest one to gate the entire response:
// entry-server.tsx — React 18 streaming with independent Suspense flushes
import { renderToPipeableStream } from 'react-dom/server';
import type { ServerResponse } from 'http';
import DashboardPage from './app/dashboard/page';
export function renderDashboard(res: ServerResponse) {
const { pipe } = renderToPipeableStream(<DashboardPage />, {
onShellReady() {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/html');
pipe(res); // Header + both fallbacks flush now; DataTable and SidebarFilters stream in independently
},
});
}And on the client, hydrateRoot — not the legacy ReactDOM.hydrate — is required to get React 18’s interaction-aware scheduling during hydration:
// entry-client.tsx — React 18 selective hydration entry point
import { hydrateRoot } from 'react-dom/client';
import DashboardPage from './app/dashboard/page';
hydrateRoot(document.getElementById('root')!, <DashboardPage />);
// React now prioritizes hydrating whichever boundary the user clicks or focuses first,
// independent of that boundary's position in the document.Step-by-step verification
- Reproduce the waterfall before the fix. Throttle CPU 4x in DevTools, reload, and click a control inside a late-hydrating boundary as early as possible. Note the delay between the click and the Performance panel’s “Commit” event for that boundary.
- Apply the per-widget boundary split shown above and rebuild.
- Repeat the same click-timing test. The delay for the same interaction should drop to roughly the hydration cost of that one boundary alone, not the sum of every boundary above it.
- Confirm out-of-order streaming in the Network tab. With the streaming server entry point active, the response should show data arriving in multiple flushes rather than one single completed transfer — visible as a growing “time” column against a single request in Chrome’s Network panel.
- Check React’s scheduling profiler. Use the React DevTools Profiler’s “Timeline” view during hydration; boundaries should show commit times that correlate with user interaction, not strictly with document order.
- Validate INP in field data. After deploying, monitor real-user INP for the affected route over the following week; a successful fix should show a measurable drop concentrated in sessions that interact with below-the-fold or secondary widgets during initial load.
Edge cases and gotchas
Gotcha 1 — over-splitting adds its own overhead
Every Suspense boundary carries a small fixed cost: additional bookkeeping in React’s fiber tree and, under streaming SSR, an extra inline script for reordering out-of-order HTML. Wrapping every individual icon or text span in its own boundary multiplies this overhead while adding no real scheduling benefit, since none of those leaves are independently code-split or meaningfully interactive on their own. Reserve boundaries for units that are both lazy-loaded and plausibly the target of independent user interaction — this mirrors the same restraint recommended for vendor chunk isolation, where over-fragmenting cache groups produces the analogous problem on the network side instead of the hydration side.
Gotcha 2 — a slow parent boundary still gates its own children
Splitting DataTable and SidebarFilters into siblings fixes contention between them, but if SidebarFilters itself contains a further lazy-loaded sub-widget without its own nested boundary, that sub-widget still hydrates as part of SidebarFilters’s block. Selective hydration is scoped to boundaries, not to components — a boundary you forgot to add doesn’t get the behavior for free just because an ancestor boundary exists.
Gotcha 3 — prefetching the chunk doesn’t replace the boundary fix
Prefetching SidebarFilters’s chunk ahead of time, using the prefetch and preload strategies covered elsewhere on this site, reduces how long its Suspense fallback shows during the initial render — but it does not change hydration scheduling once the HTML has streamed. If SidebarFilters shares a boundary with a slower sibling, prefetching its chunk earlier just means it’s ready and waiting when its turn in the (still serialized) hydration order finally comes. Boundary placement and prefetching solve different halves of the same latency budget; apply both, but don’t expect one to substitute for the other.
Gotcha 4 — measuring the wrong thing in synthetic Lighthouse runs
Lighthouse and other lab-based tools drive a page with a scripted interaction sequence that rarely matches real usage, so a hydration waterfall that primarily affects a secondary widget — the sidebar filter in the example above, not the primary content — can pass a synthetic audit cleanly while still degrading INP for real users who interact with that secondary widget first. Treat lab scores as a sanity check, not proof the fix worked; the verification steps above that rely on field INP data and manual interaction timing are the ones that actually catch this failure mode, because they exercise the exact boundary a synthetic script is unlikely to touch.
FAQ
What exactly is a hydration waterfall?
A hydration waterfall is when a page’s Suspense-wrapped sections attach their event listeners strictly in document order, one after another, instead of based on which one the user is actually trying to interact with. A widget near the bottom of the DOM can end up waiting on every widget above it to finish hydrating first, even if the user clicked it immediately.
Does selective hydration require code splitting, or is it a separate feature?
They are separate mechanisms that compound well together. Selective hydration is a React 18 scheduling behavior that works even without lazy-loaded chunks. Code splitting determines when a component’s JavaScript arrives at all. Combining them means a chunk that hasn’t even finished downloading yet can still be prioritized the moment it does arrive, based on user interaction rather than document position.
Why does adding more Suspense boundaries sometimes make things slower?
Each Suspense boundary adds a small amount of bookkeeping overhead and, in streaming SSR, an additional inline script for reordering out-of-order content. Wrapping every individual span or icon in its own boundary multiplies this overhead without adding meaningful hydration flexibility. Reserve boundaries for genuinely independent, code-split units — a widget, a panel, a below-the-fold section — not every leaf node.
Related
- Code Splitting for SSR and React Server Components — the parent cluster covering the broader server/client rendering split this page’s hydration behavior depends on
- Using dynamic import() in React Server Components — a companion guide on drawing the Server/Client Component boundary these Suspense boundaries sit inside
- Invalidating Service Worker Cache After Deploy — a companion guide on keeping the chunks these boundaries stream in from going stale after a deploy
- Prefetch and Preload Strategies for Critical Routes — the complementary network-side latency strategy referenced above
- Route-Based Code Splitting & Dynamic Import Strategies — the section defining the chunk boundaries this page’s Suspense boundaries are layered on top of