How to Use Selective Hydration with React Suspense
This guide covers React's built-in hydration scheduling, within Hydration & Partial Rehydration Strategies and JavaScript Bundle Optimization & Code Splitting. Before React 18, hydrate() processed the entire tree in one synchronous pass: a page with heavy components produced a single long task, and any tap during it waited. React 18's hydrateRoot with Suspense boundaries changes that. Each boundary hydrates independently, React yields between boundaries so the browser can handle input, and if the user interacts with a boundary that has not hydrated yet, React prioritises it.
The behaviour is automatic — but only where boundaries exist. A page with one Suspense boundary (or none) still hydrates as one large unit. Placing boundaries around independent regions is what turns React's capability into real INP and TBT improvements.
Rapid Diagnosis
- Check the root API.
ReactDOM.hydrate(legacy) cannot do selective hydration; you needhydrateRoot(React 18+), which frameworks like Next.js use. - Count Suspense boundaries around page regions. Zero or one means hydration is effectively monolithic.
- Trace hydration. In a throttled trace, look for one long task under
hydrateRootversus several shorter tasks. - Check early-interaction INP. High input delay for interactions in the first seconds indicates hydration blocking.
Root Cause Analysis
1. No boundaries. Without Suspense boundaries, React treats the tree as one unit and cannot yield inside it.
2. Boundaries in the wrong place. A boundary around a tiny component does little; the long work is in large regions without boundaries.
3. Synchronous data access. Components that read synchronous global stores during hydration cannot be split meaningfully.
4. Legacy root API. Apps upgraded to React 18 but still calling ReactDOM.hydrate keep legacy behaviour.
Step-by-Step Resolution
1. Use hydrateRoot
import { hydrateRoot } from 'react-dom/client';
hydrateRoot(document.getElementById('root'), <App />, {
onRecoverableError: (err) => reportError(err), // surface hydration mismatches
});
// trade-off: switching from the legacy API also opts into concurrent features
// and stricter hydration error handling; test for mismatches before shipping.
Expected outcome: React can hydrate boundaries independently.
2. Wrap independent regions in Suspense
export function ProductPage({ product }) {
return (<>
<Header />
<Suspense fallback={<GallerySkeleton />}><Gallery images={product.images} /></Suspense>
<Suspense fallback={<BuyBoxSkeleton />}><BuyBox product={product} /></Suspense>
<Suspense fallback={<ReviewsSkeleton />}><Reviews productId={product.id} /></Suspense>
<Suspense fallback={<RecsSkeleton />}><Recommendations productId={product.id} /></Suspense>
</>);
}
// trade-off: with server rendering, fallbacks are only shown if a region
// suspends during SSR or the client. Size them like the real content anyway —
// a mismatched fallback becomes a layout shift whenever it does appear.
Expected outcome: hydration splits into boundary-sized tasks, and React yields between them.
3. Lazy-load code for heavy regions
Combine boundaries with React.lazy (or next/dynamic) for heavy below-the-fold regions so their code downloads in parallel and hydrates when ready, rather than being part of the main bundle that must evaluate before anything hydrates.
4. Stream the HTML for each boundary
With streaming SSR (renderToPipeableStream / Next.js App Router), boundaries also stream: the server sends the shell first and fills boundaries as their data resolves, improving TTFB and LCP alongside hydration.
Verification
Record a throttled trace: hydration should appear as several shorter tasks, ideally each under 50ms–100ms, with gaps where input can be processed. Tap the buy box during hydration and confirm in the Interactions track that its input delay is short and that its boundary hydrated before later ones. In the field, INP for interactions in the first few seconds should fall; segment by time since load to see it.
Worked Example: Prioritising Add-to-Cart
A retailer's product page hydrated as one 760ms task on mid-tier phones; users who tapped "Add to cart" during load waited up to the full hydration time, and field INP for that button was 410ms at p75. Adding five Suspense boundaries (header, gallery, buy box, reviews, recommendations) and lazy-loading reviews and recommendations split hydration into tasks of 60–180ms. When users tapped the buy box mid-hydration, React hydrated it next and replayed the tap. Add-to-cart INP p75 fell to 170ms, and lab TBT dropped from 690ms to 260ms.
Common Mistakes
- One boundary around the whole page. That is the same as no boundary for hydration purposes.
- Hundreds of tiny boundaries. Each has overhead; aim for one per independently interactive region.
- Unsized fallbacks. If a region suspends on the client, an undersized fallback shifts layout.
- Assuming boundaries split a single heavy component. A boundary cannot split work inside one component; a 300ms component is still a 300ms task.
Edge Cases
Context providers above boundaries. Providers hydrate before their children; heavy provider initialisation still runs up front.
Event types. React prioritises discrete events (clicks, key presses) for unhydrated boundaries; continuous events like scroll or mouse move do not trigger prioritisation.
Third-party scripts during hydration. Selective hydration yields to the browser, which may run third-party tasks in the gaps; those can still delay interactions — see reducing input delay from third-party tags.
Older React versions in micro-frontends. Mixed React versions on one page each hydrate on their own terms; only React 18+ roots benefit.
Measuring the Effect in the Field
Selective hydration changes when work happens, so measure it in the window where it matters. Tag each INP beacon with the time since navigation start and with whether hydration had completed (a hydrated mark set in the root component's first effect). Compare INP for interactions before hydration completed against those after, before and after adding boundaries. A successful change shows the "before hydration" bucket shrinking towards the "after" bucket. Also track the share of sessions with an interaction before hydration completed: if it is small, the user-facing benefit is small too, and lab TBT improvements may be the main gain.
useEffect(() => { performance.mark('hydrated'); }, []); // in the root component
// trade-off: the root's effect runs after all boundaries have hydrated, so
// this marks full hydration; mark per boundary if you need finer detail.
FAQ
Does Next.js do this automatically?
Next.js uses hydrateRoot and streaming, so selective hydration is available; whether it helps depends on your Suspense boundaries. loading.js files create boundaries per route segment; add explicit Suspense boundaries inside pages for finer control.
Is selective hydration the same as lazy hydration?
No. Selective hydration still hydrates everything as soon as possible, just in prioritised, interruptible chunks. Lazy hydration delays hydration of some components until a trigger (visible, idle, interaction). They combine well.
Can I control the order boundaries hydrate in?
Not directly; React hydrates roughly in document order and promotes boundaries the user interacts with. You can influence order by lazy-loading low-priority regions so their code arrives later.
Does this help LCP?
Indirectly. Hydration does not usually delay the first paint of server-rendered content, but shorter hydration tasks reduce main-thread contention for image decoding and late rendering, and streaming boundaries improve TTFB.
How do I measure hydration per boundary?
Add performance.mark calls in each boundary's first effect, or use the React DevTools profiler on a production profiling build. In traces, React's scheduler tasks during hydration can be correlated with component names in development builds.
Do boundaries change what users see during normal loads?
With server rendering, usually not: the server HTML for every boundary is already on screen, and hydration attaches behaviour without visible change. Fallbacks appear only when a boundary actually suspends — for lazy code that has not loaded or data that is not ready — which is why sizing them correctly still matters.
Related
- Fixing long hydration tasks in Next.js App Router — App Router specifics.
- Fixing slow first interactions during hydration — measuring early-interaction INP.
- Streaming SSR with React Suspense — the server half of the same boundaries.