4.4 — Hydration cost¶
Module 4 · Lesson 4 · 🔴 Advanced · ~35 min
What you'll learn¶
- What hydration actually does, and why it's often the largest main‑thread block on a commerce page
- How to measure hydration directly rather than inferring it
- Selective hydration, islands, and reducing the number of hydration roots
- Hydration mismatches: why they cost double and how to eliminate them
What hydration does¶
Server rendering produces HTML. That HTML is visible but inert — no event handlers, no state. Hydration is React walking the tree, re‑executing every Client Component, building the fiber tree, and attaching event listeners so the DOM becomes interactive.
HTML paints at 1.7s ← user sees the product page
↓ looks done, isn't
Hydration 2.1s → 2.7s ← 610ms of main-thread work
↓
Interactive at 2.7s ← taps before this are queued, then run late
The gap between "looks ready" and "is ready" is where users tap and nothing happens. It's the single biggest contributor to bad INP during page load, and it's invisible in LCP.
Hydration cost scales with:
| Factor | Effect |
|---|---|
| Number of Client Components | Each one re‑executes |
| Component tree depth and node count | Fiber construction |
| Props size (the RSC payload) | Deserialization |
Work in component bodies (useMemo, formatters, derived state) |
Runs during hydration |
| Effects that run immediately | useEffect fires after hydration, extending the block |
| Device CPU | 4–6× multiplier on mid‑tier mobile |
Measuring it¶
Don't infer hydration cost from bundle size. Measure it.
Method 1 — a direct mark¶
// app/components/hydration-timing.tsx
'use client';
import { useEffect } from 'react';
export function HydrationTiming({ page }: { page: string }) {
useEffect(() => {
// This effect runs after hydration completes for this tree
const nav = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
const hydrationEnd = performance.now();
// responseEnd ≈ when HTML finished arriving
const value = hydrationEnd - nav.responseEnd;
performance.measure('hydration', {
start: nav.responseEnd,
end: hydrationEnd,
});
reportMetric({ name: 'hydration_complete', value, page });
}, [page]);
return null;
}
Place it as the last child of the outermost client tree so its effect runs after the others. This is approximate — effects fire in tree order and React may hydrate selectively — but it's a consistent, trackable number, which is what you need.
Method 2 — the Performance panel (authoritative)¶
- Record a page load at 4× CPU throttling.
- Find the long task after the HTML finishes parsing.
- Expand the call tree — you're looking for React internals:
performWorkUntilDeadline→renderRootSync/hydrateRoot→ your component names. - That task's width is your hydration cost.
React 18+ also emits User Timing marks visible in the Timings track when you use the profiling build, which makes this easier to spot.
Method 3 — React Profiler¶
React DevTools → Profiler → check "Record why each component rendered" → reload with profiling on. The first commit is hydration. Sort by "self time" to find the expensive components.
Aurora's PDP baseline: 610 ms of hydration on a mid‑tier Android, across 340 Client Components in 12 separate hydration roots.
Reducing hydration cost¶
1. Fewer Client Components (the main lever)¶
This is 3.2 again, from the hydration angle. A component that isn't a Client Component isn't hydrated at all.
The highest‑value question to ask of any 'use client' file: does this component have an event
handler, state, an effect, or a browser API? If not, it doesn't need to be a Client Component —
even if its parent is.
2. Less work in component bodies¶
Everything in a Client Component's body runs during hydration, on the main thread, at 6× cost.
// ❌ Runs for all 48 tiles during hydration
'use client';
export function ProductCard({ product, locale }: Props) {
const price = new Intl.NumberFormat(locale, { // ~1ms each × 48 = 48ms
style: 'currency', currency: product.currency,
}).format(product.price / 100);
const badges = useMemo( // runs on hydration anyway
() => computeBadges(product),
[product],
);
return /* … */;
}
// ✅ Compute on the server; the client gets strings
// Server Component
export function ProductCard({ product, locale }: Props) {
const price = formatPrice(product.price, product.currency, locale); // server, free
const badges = computeBadges(product); // server, free
return (
<article>
<PriceDisplay value={price} />
<BadgeList badges={badges} />
<WishlistButton productId={product.id} /> {/* the only client leaf */}
</article>
);
}
useMemodoes not save you during hydration. It computes on first render, and hydration is the first render. Memoization helps subsequent renders, not this one.
3. Defer effects that don't need to run immediately¶
useEffect callbacks run right after hydration and extend the blocking period.
// ❌ Analytics init, intersection observers, and a resize listener all fire
// immediately after hydration, extending the block by ~120ms
useEffect(() => {
initAnalytics();
setupScrollTracking();
measureViewport();
}, []);
// ✅ Yield first — let the browser paint and handle input, then do the work
useEffect(() => {
const run = () => {
initAnalytics();
setupScrollTracking();
measureViewport();
};
const id = 'requestIdleCallback' in window
? requestIdleCallback(run, { timeout: 2000 })
: setTimeout(run, 200);
return () => {
'cancelIdleCallback' in window ? cancelIdleCallback(id as number) : clearTimeout(id);
};
}, []);
4. Progressive/lazy hydration for below‑fold interactivity¶
A component the user may never scroll to doesn't need to be interactive on load.
// components/hydrate-on-visible.tsx
'use client';
import { useEffect, useRef, useState, type ReactNode } from 'react';
/**
* Renders server HTML immediately; mounts the interactive version only when
* the element approaches the viewport. Below-the-fold interactivity only —
* never wrap something the user might tap immediately.
*/
export function HydrateOnVisible({
children,
fallbackHtml,
minHeight,
}: {
children: ReactNode;
fallbackHtml: string;
minHeight: number;
}) {
const ref = useRef<HTMLDivElement>(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: '300px' },
);
io.observe(ref.current);
return () => io.disconnect();
}, [hydrated]);
if (hydrated) return <div ref={ref}>{children}</div>;
return (
<div
ref={ref}
style={{ minHeight }}
dangerouslySetInnerHTML={{ __html: fallbackHtml }}
/>
);
}
This is a real technique with real caveats: the non‑hydrated version can't respond to interaction, so it must be genuinely non‑interactive content, and you need the server HTML available to inject. In an RSC codebase, prefer just making it a Server Component — same result, less machinery.
5. Suspense creates selective hydration boundaries¶
React hydrates Suspense boundaries independently and prioritizes the one the user interacts with.
Wrapping independent regions in <Suspense> means a tap on the size selector gets that region
hydrated first, rather than waiting for the whole page.
<Suspense fallback={<GallerySkeleton />}>
<ProductGallery images={product.images} />
</Suspense>
<Suspense fallback={<ActionsSkeleton />}>
<ProductActions productId={product.id} /> {/* hydrated first if tapped first */}
</Suspense>
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews productId={product.id} />
</Suspense>
This doesn't reduce total hydration work — it reorders it so the work the user is waiting on happens first. That's an INP win even at constant CPU cost.
Hydration mismatches¶
A mismatch means the server HTML and the client's first render disagree. React logs an error and discards the server HTML for that subtree, re‑rendering it from scratch — you paid for SSR and got CSR, plus a visible flash and often a layout shift.
The five causes¶
// 1. Non-deterministic values
<span>{Date.now()}</span> // ❌ different on server and client
<span>{Math.random()}</span> // ❌
// 2. Browser-only APIs during render
const isMobile = window.innerWidth < 768; // ❌ crashes on server or diverges
// 3. Locale/timezone-dependent formatting without a fixed locale
new Date(iso).toLocaleDateString() // ❌ server TZ ≠ client TZ
new Intl.DateTimeFormat().format(d) // ❌ implicit locale differs
// 4. Reading client storage during render
const theme = localStorage.getItem('theme'); // ❌
// 5. Invalid HTML nesting that the browser corrects
<p><div>…</div></p> // ❌ browser moves the div; React disagrees
The fixes¶
// 1 & 3. Compute on the server, pass down, and always specify locale + timeZone
// Server Component
const formatted = new Intl.DateTimeFormat(locale, {
dateStyle: 'medium', timeZone: 'UTC',
}).format(new Date(order.createdAt));
<OrderDate value={formatted} />
// 2. Use CSS for responsive behavior, not JS
<div className="block md:hidden">Mobile nav</div>
<div className="hidden md:block">Desktop nav</div>
// If you truly need the value in JS, read it after mount
const [isMobile, setIsMobile] = useState<boolean | null>(null);
useEffect(() => {
const mq = window.matchMedia('(max-width: 767px)');
const update = () => setIsMobile(mq.matches);
update();
mq.addEventListener('change', update);
return () => mq.removeEventListener('change', update);
}, []);
if (isMobile === null) return <ServerSafeDefault />;
// 4. Read storage in an effect, with a server-safe initial value
const [recentlyViewed, setRecentlyViewed] = useState<string[]>([]);
useEffect(() => {
setRecentlyViewed(JSON.parse(localStorage.getItem('recent') ?? '[]'));
}, []);
// 5. Validate nesting — a linter rule catches most of these
For values that are legitimately client‑only and unavoidable, suppressHydrationWarning silences
the warning for that element's text content only. Use it sparingly and never on a subtree:
Finding mismatches in production¶
Development shows them in the console. Production doesn't — so they persist silently for months.
// app/components/hydration-error-reporter.tsx
'use client';
import { useEffect } from 'react';
export function HydrationErrorReporter() {
useEffect(() => {
const original = console.error;
console.error = (...args: unknown[]) => {
const msg = String(args[0] ?? '');
if (msg.includes('Hydration') || msg.includes('did not match')) {
reportMetric({
name: 'hydration_mismatch',
value: 1,
attribution: { message: msg.slice(0, 500), path: location.pathname },
});
}
original.apply(console, args as never);
};
return () => { console.error = original; };
}, []);
return null;
}
Better: React 19's onRecoverableError on the root, or your error boundary's reporting hook. Wire
whichever your setup supports, and alert on the rate — a spike means someone shipped a
mismatch.
Aurora found 4 mismatches this way, one of which (a toLocaleDateString() on the delivery estimate)
was causing the entire product actions subtree to re‑render client‑side on every load, costing
~190 ms.
The islands mental model¶
RSC gives you islands architecture by default: server‑rendered content with isolated interactive islands. The goal is fewer, smaller islands.
❌ One big island (the whole page is client)
┌────────────────────────────────────────┐
│░░░░░░░░░░░░░ all hydrated ░░░░░░░░░░░░░│ 610ms
└────────────────────────────────────────┘
✅ Small islands in a sea of server HTML
┌────────────────────────────────────────┐
│ static │░wishlist░│ static │
│ static │░ variant ░│ ░cart░│ static │ 180ms
│ static │ static │ static │
└────────────────────────────────────────┘
Track the count as a metric alongside bundle size:
# Number of files that create client boundaries
rg -c "^'use client'" app components --stats | tail -3
# Number of hydration roots at runtime (paste in the console)
document.querySelectorAll('[data-reactroot], template[id^="S:"]').length
Common mistakes¶
| Mistake | Cost |
|---|---|
| Assuming SSR means fast interactivity | Content visible ≠ interactive; the gap is where taps are lost |
| Expensive computation in Client Component bodies | Runs during hydration at 6× cost |
Relying on useMemo to help hydration |
It doesn't — hydration is the first render |
| Effects firing immediately after hydration | Extends the blocking window |
| Ignoring hydration mismatches | Silent full client re‑render of a subtree |
| Locale/timezone formatting on the client | Guaranteed mismatch |
| Not measuring hydration at all | It's invisible in LCP and in bundle size |
| Lazy‑hydrating something above the fold | Broken interactivity where users tap first |
Lab 4.4 — Measure and cut hydration¶
- Measure: add the hydration timing component and record a 4×‑throttled trace. Write down the number for your three main page types.
- Count islands: how many
'use client'files contribute to each page? How many are genuinely interactive? - Find expensive bodies: React Profiler → first commit → sort by self time. The top 5 components are your targets.
- Move work to the server: formatters, derived data, badge logic. Re‑measure.
- Defer effects that don't need to run immediately.
- Hunt mismatches: load every page type in dev with the console open. Fix all of them. Then ship the production reporter and watch for a week.
- Add
<Suspense>boundaries around independent interactive regions for selective hydration.
Target: hydration under 200 ms at 4× CPU throttling.
Checklist¶
- Hydration time measured per page type, tracked over time
- Client Component count known and trending down
- No expensive computation in Client Component bodies
- Formatters constructed once, not per render
- Effects deferred to idle where possible
- Zero hydration mismatches in dev
- Production mismatch reporting wired with an alert
- Suspense boundaries around independent interactive regions
- Nothing above the fold is lazily hydrated
Next: Module 5 — React runtime