Hydration & Partial Rehydration Strategies: Paying Only for the Interactivity You Ship
This guide extends the delivery work in JavaScript Bundle Optimization & Code Splitting past the point where the bytes arrive: hydration is what those bytes then do to the main thread.
Server-side rendering solves the paint problem and creates an execution problem. The HTML arrives early and the browser paints it quickly, so LCP looks healthy — then the framework walks the entire component tree, re-creates the virtual DOM, attaches every event listener, and runs every effect, all in one uninterruptible task. On a mid-tier phone that task routinely runs 300–900ms. Any tap during that window queues behind it, which is exactly how a page with a 1.4s LCP ships a 600ms INP and fails the 200ms threshold on a metric its own architecture was supposed to protect.
The strategies below all answer one question: which components genuinely need client-side JavaScript, and when. Everything else is markup that has already done its job.
The Cost Model You Are Optimizing
Hydration cost is not proportional to bundle size; it is proportional to component count, tree depth, and the work each component does on mount. Three quantities matter.
Component instantiation. Every component in the server-rendered tree is re-created on the client so the framework can own it. A page with 800 component instances pays 800 instantiations even if 780 of them will never change. This is the term that islands architecture attacks directly.
Effect execution. Mount effects run during or immediately after hydration: subscriptions, media queries, analytics registration, layout measurement. A single component measuring its own size with getBoundingClientRect in a mount effect forces a synchronous layout; multiply by fifty instances and the hydration task grows a forced-reflow tail that no profiler flame graph attributes to hydration.
Deserialization. The server embeds the props for every component as JSON. A 400KB embedded payload costs main-thread time to parse before hydration even begins, and it inflates the HTML document that LCP depends on. Large embedded state is the most commonly missed half of hydration cost.
The practical target: keep any single hydration task under the 50ms long-task budget, and keep total blocking time from hydration under 200ms on a mid-tier device profile. Both are achievable without giving up server rendering.
Prerequisites
- A server-rendered app on a framework with a hydration boundary you can control — React 18+ (
hydrateRoot,Suspense, RSC), Vue 3 / Nuxt (<ClientOnly>, lazy hydration), Svelte, Astro, or Qwik. - The Performance panel with 4× or 6× CPU throttling. Hydration cost is invisible on a desktop CPU; every number in this guide assumes a throttled profile that approximates a mid-tier Android device.
- A route-level bundle report so you can tell which components ship JavaScript at all — see Webpack Bundle Analysis Techniques for producing one you can trust.
- Field INP data with interaction attribution, so you can confirm the fix moved the metric for real users and not just in the lab. The pipeline for that is in RUM Beacons & Field Data Collection.
1. Measure the Hydration Task, Not the Bundle
Record a throttled load and find the single longest task between first paint and the app becoming interactive. In React it is the task containing hydrateRoot; in Nuxt it is the one containing mountApp. Read three numbers off it: total duration, the self-time of the framework's reconciliation, and the time spent inside your own component code.
// Mark hydration explicitly so it is measurable in the field, not just the lab.
performance.mark('hydrate:start');
hydrateRoot(document.getElementById('root'), <App />);
requestAnimationFrame(() => requestAnimationFrame(() => {
performance.mark('hydrate:end');
const m = performance.measure('hydration', 'hydrate:start', 'hydrate:end');
if (m.duration > 200) reportSlowHydration(Math.round(m.duration));
}));
// trade-off: the double rAF approximates "after the first post-hydration paint",
// which is close enough for trend tracking but not exact. Do not use it to make
// pass/fail claims about INP — use event-timing entries for that.
If the framework's own reconciliation dominates, you have too many components and need architectural splitting. If your component code dominates, you have expensive mount effects and can often fix the metric without changing architecture at all.
2. Classify Every Component by Interactivity
Before choosing a technique, produce an honest inventory. Walk the route's tree and put each component into one of three buckets.
| Bucket | Description | Correct treatment |
|---|---|---|
| Static | Renders once, never responds to input — headings, prose, footers, breadcrumbs | Ship zero JavaScript; never hydrate |
| Deferred | Interactive but not immediately — carousels, comment threads, below-fold filters | Hydrate on visibility or first interaction |
| Critical | Needed before the user's first likely action — nav, search box, add-to-cart | Hydrate eagerly, first in order |
The result is usually uncomfortable: on a typical content or commerce route, 70–85% of component instances land in "static", and they are the reason the hydration task is long. The remaining critical set is small enough to hydrate in a task that finishes well inside budget.
3. Cut the Static Majority: Islands
Islands architecture inverts the default. Instead of hydrating everything and opting components out, the page ships as HTML and only explicitly marked components become interactive. Each island hydrates independently, so there is no single tree-wide task to block on.
// Astro-style island directives: the framework emits a hydration task per island.
<SiteHeader /> {/* no directive → zero JS, pure HTML */}
<SearchBox client:load /> {/* critical: hydrate immediately */}
<ProductFilters client:idle /> {/* deferred: hydrate when the thread is free */}
<ReviewCarousel client:visible /> {/* deferred: hydrate when scrolled into view */}
{/* trade-off: islands cannot share client-side state through React context or a
component-tree store, because they are separate roots. Cross-island state
needs an external store (signals, a tiny event bus, or URL state) — retrofit
this into a context-heavy app and the refactor is real work. */}
The state-sharing constraint is the honest cost of the model, and it is why islands suit content, documentation, marketing and catalogue pages far better than dashboards. The decision framework — including the cases where full hydration remains the right answer — is worked through in islands vs full hydration for content sites.
4. Defer What Is Not Yet Visible
Where a full islands migration is not on the table, deferring is the highest-yield change you can make inside an existing framework: keep the component in the tree, but delay its hydration until the user can plausibly interact with it.
// React: a boundary that renders server HTML but defers client hydration
// until the subtree scrolls into view.
function LazyHydrate({ children }) {
const ref = useRef(null);
const [hydrated, setHydrated] = useState(false);
useEffect(() => {
if (hydrated || !ref.current) return;
const io = new IntersectionObserver(([e]) => {
if (e.isIntersecting) { setHydrated(true); io.disconnect(); }
}, { rootMargin: '200px' }); // start early so it is ready on arrival
io.observe(ref.current);
return () => io.disconnect();
}, [hydrated]);
if (hydrated) return children;
return <div ref={ref} suppressHydrationWarning
dangerouslySetInnerHTML={{ __html: '' }} />;
}
// trade-off: the placeholder must preserve the server HTML and its exact box,
// or you trade an INP win for a CLS regression. Measure the shift before and
// after — a deferred component that collapses to zero height is a net loss.
The 200px root margin is the tuning knob: too small and the component is still hydrating when the user reaches it; too large and you have re-created eager hydration with extra steps. The full pattern, including how to keep server markup intact and how to force hydration on focus for keyboard users, is in deferring below-the-fold hydration with IntersectionObserver.
5. Stream and Prioritize with Selective Hydration
React's streaming SSR plus Suspense gives you a middle path that needs no architectural change: wrap slow or low-priority subtrees in Suspense, and the runtime hydrates them independently, out of order, prioritising whichever boundary the user actually interacts with.
// Each boundary is a separate hydration unit; a click inside one promotes it.
<Layout>
<Nav /> {/* hydrates first */}
<Suspense fallback={<FeedSkeleton />}>
<Feed /> {/* streams, hydrates independently */}
</Suspense>
<Suspense fallback={<PanelSkeleton />}>
<Recommendations />
</Suspense>
</Layout>
// trade-off: every boundary adds a fallback that must not shift layout, and the
// streamed chunks arrive as separate flushes — a boundary around a tiny
// component costs more in protocol overhead than it saves in scheduling.
Selective hydration converts one 620ms task into several sub-100ms tasks with yield points between them. It does not reduce total CPU work — the same components still hydrate — so it improves INP and interaction latency without improving total blocking time much. When the goal is to remove work rather than reschedule it, islands or client:visible-style deferral is the stronger tool.
Deconstructing the Hydration Timeline
Split the interval between paint and interactivity into four phases; each has a different fix.
- Payload parse — the embedded state JSON is deserialized. Fix by shrinking what the server serialises: send IDs and refetch, not fully expanded objects. Target under
30ms. - Tree walk — the framework re-creates component instances. Fix by reducing the number of hydrated components: islands and deferral. This phase scales with component count, not bundle bytes.
- Effect flush — mount effects run. Fix by moving measurement and subscription work out of mount into an idle callback, and by never calling layout-reading APIs in a mount effect that runs for many instances.
- Post-hydration settle — data fetches triggered by mount resolve and re-render. Fix by fetching on the server so the first client render is already final.
A common misdiagnosis is attributing a slow settle phase to hydration. If the profile shows a short hydration task followed by a second long task 200ms later, the problem is client-side data fetching, not hydration, and the fix belongs on the server.
Advanced Diagnostics
Hydration mismatch is a performance bug, not just a warning. When server and client markup disagree, the framework discards the server subtree and re-renders it from scratch — the cost of SSR with none of the benefit. The usual culprits are Date-dependent output, window-dependent branches, and locale formatting. Treat every mismatch warning as a regression: it silently doubles the work for that subtree.
Long mount effects hide inside "framework" time. Chrome's flame graph attributes effect execution to the framework's flush, so a slow useEffect in one leaf component looks like framework overhead. Use performance.measure inside suspect effects to pull them out of the aggregate before concluding the framework is the problem.
Third-party components can force eager hydration. A chat widget or a consent manager that mounts a React root of its own restarts the whole cost model outside your control. Isolate them behind a facade or a worker so they cannot compete for the same thread — the techniques are covered in Third-Party Script Performance.
Route transitions re-run the cost. In an SPA, navigating to a heavy route mounts a large tree without SSR's paint advantage. Apply the same classification to route entry: render the shell eagerly, defer everything below the fold, and prefetch the next route's chunk on intent rather than on load — see dynamic imports and route-based splitting.
Validation and Budgeting
Assert on the two things that actually change: total blocking time in the lab, and INP in the field.
{
"ci": {
"assert": {
"assertions": {
"total-blocking-time": ["error", { "maxNumericValue": 200 }],
"mainthread-work-breakdown": ["warn", { "maxNumericValue": 2000 }],
"bootup-time": ["error", { "maxNumericValue": 1500 }],
"interactive": ["warn", { "maxNumericValue": 3500 }]
}
}
}
}
Total blocking time is the right lab proxy for hydration cost because it aggregates exactly the blocking this work creates. Set the CI device profile to a throttled mobile emulation and keep it fixed — a change of CPU multiplier between runs makes the whole series meaningless.
In the field, watch INP segmented by whether the interaction occurred within two seconds of navigation. A pre-settle spike that disappears after two seconds is definitively hydration, and it is the population that a fix here should move first.
FAQ
Does server-side rendering make hydration cost unavoidable?
No — it makes it visible. The work exists in any client-rendered app too; SSR simply moves it after a fast paint, where it shows up as interaction delay instead of a blank screen. What is avoidable is hydrating components that will never respond to input, which on a typical content route is the large majority of the tree. Islands, deferral and selective hydration all reduce that set; none of them require giving up server rendering.
Will code splitting fix a slow hydration task?
Only partially. Splitting reduces the bytes that must be downloaded, parsed and compiled, which shortens the phase before hydration starts. It does not reduce the number of components hydrated, so the tree walk stays the same length. If your profile shows a long task dominated by framework reconciliation, splitting will barely move it — you need fewer hydrated components, not smaller chunks.
Is lazy hydration safe for SEO?
Yes, when the server HTML is preserved. Crawlers read the rendered markup, which is fully present before any hydration happens — that is the whole point of server rendering. The pattern becomes unsafe only if the placeholder discards the server HTML and renders an empty box until the component hydrates, which is also a layout-shift bug. Keep the markup, defer only the JavaScript.
How do I stop deferred components from causing layout shift?
Reserve the exact box the hydrated component will occupy, and prefer keeping the server-rendered markup in place rather than substituting a skeleton. A shift is only recorded if the geometry changes, so a component that hydrates in place — same HTML, now with listeners attached — contributes nothing to CLS. Verify with the Layout Shift Regions overlay while scrolling past each deferred boundary.
Related
- Dynamic Imports & Route-Based Splitting — get the chunk boundaries right before tuning when each chunk executes.
- Optimizing INP with scheduler.yield() — yield inside the work that hydration cannot avoid running.
- Islands vs full hydration for content sites — the architectural decision, with its state-sharing costs.
- Fixing long hydration tasks in Next.js App Router — server components, client boundaries and the payload that travels between them.
- Deferring below-the-fold hydration with IntersectionObserver — the concrete deferral pattern without a framework migration.
- Third-Party Script Performance — keep external code from re-inflating the main thread you just cleared.