Measuring React Hydration’s Impact on LCP
Server-rendered React ships HTML the browser paints quickly, but the page is inert until hydrateRoot attaches event handlers and reconciles the tree. That hydration work runs as one or more Long Tasks on the main thread, and when the largest contentful element renders inside or just after that window, hydration becomes the hidden tail on your Largest Contentful Paint — and the prime suspect behind a poor first Interaction to Next Paint. This guide is part of Framework Performance Instrumentation, and it shows how to measure the hydration cost precisely with the PerformanceObserver and User Timing APIs, correlate it against LCP renderTime, and reduce it with streaming, selective hydration, React Server Components, and islands.
Prerequisites
- A React 18+ app using
hydrateRoot(or a framework wrapping it: Next.js, Remix, custom SSR). Thereact-dom/serverstreaming API (renderToPipeableStream) is what unlocks the reduction strategies later. - The
web-vitalslibrary installed (npm i web-vitals) so you can read LCP attribution without reimplementing the observer. - Access to a real device-class spread in your RUM data — hydration cost scales with CPU, so a measurement taken only on a fast laptop will under-report the mobile tail.
- A way to ship custom timings to your collector (a sendBeacon-based ingestion endpoint). Hydration timing is a custom metric, not a standard vital.
How to measure and reduce hydration’s LCP impact
1. Mark the hydration window with the User Timing API
Wrap hydrateRoot in a performance.mark / performance.measure pair so the hydration duration becomes a first-class entry in the performance timeline.
import { hydrateRoot } from 'react-dom/client';
import App from './App';
performance.mark('hydration:start');
const root = hydrateRoot(document.getElementById('root'), <App />, {
onRecoverableError(error) {
// Hydration mismatches recover by re-rendering on the client — that
// re-render is extra main-thread work, so record it, don't swallow it.
performance.mark('hydration:mismatch');
console.warn('Recoverable hydration error', error);
},
});
// hydrateRoot returns synchronously, but the actual reconciliation can be
// scheduled. requestIdleCallback fires after React has flushed the initial
// hydration commit, giving a realistic end boundary.
requestIdleCallback(() => {
performance.mark('hydration:end');
performance.measure('hydration', 'hydration:start', 'hydration:end');
});
Why: performance.measure('hydration', …) produces a named PerformanceMeasure entry you can read back anywhere, correlate with LCP, and beacon out. Marking inside onRecoverableError lets you separate clean hydration from the far more expensive mismatch-recovery path, which forces React to throw away server HTML and re-render on the client.
2. Observe Long Tasks during hydration
Hydration almost always produces at least one Long Task (> 50 ms). Capture them with a longtask observer and attribute the ones that fall inside the hydration window.
const hydrationTasks = [];
const longTaskObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
hydrationTasks.push({ start: entry.startTime, duration: entry.duration });
}
});
// buffered:true replays long tasks that fired before this observer attached —
// critical, because the worst hydration task often precedes your JS executing.
longTaskObserver.observe({ type: 'longtask', buffered: true });
function summariseHydrationBlocking() {
const [measure] = performance.getEntriesByName('hydration', 'measure');
if (!measure) return null;
const windowEnd = measure.startTime + measure.duration;
const blocking = hydrationTasks
.filter((t) => t.start < windowEnd && t.start + t.duration > measure.startTime)
.reduce((sum, t) => sum + Math.max(0, t.duration - 50), 0); // TBT-style
return { hydrationDuration: measure.duration, blockingTime: blocking };
}
Why: The hydration measure tells you wall-clock duration; the longtask entries tell you how much of that was uninterruptible main-thread blocking (the part that delays input handling and inflates INP). Subtracting 50 ms per task mirrors how Total Blocking Time is computed, so the number maps to a metric engineers already reason about.
3. Correlate hydration end with LCP renderTime
The decisive question is whether LCP rendered before or after hydration finished. Read LCP via the web-vitals library and compare renderTime to the hydration window.
import { onLCP } from 'web-vitals';
onLCP((metric) => {
const lcpEntry = metric.entries[metric.entries.length - 1];
const lcpTime = lcpEntry.renderTime || lcpEntry.loadTime; // renderTime may be 0 cross-origin
const [measure] = performance.getEntriesByName('hydration', 'measure');
const hydrationEnd = measure ? measure.startTime + measure.duration : null;
const blocked = summariseHydrationBlocking();
beacon('lcp-hydration', {
lcp: Math.round(metric.value),
lcpRenderTime: Math.round(lcpTime),
hydrationEnd: hydrationEnd ? Math.round(hydrationEnd) : null,
// Positive gap => LCP rendered AFTER hydration: hydration is on the path.
gapMs: hydrationEnd ? Math.round(lcpTime - hydrationEnd) : null,
blockingTime: blocked ? Math.round(blocked.blockingTime) : null,
});
});
function beacon(name, data) {
const body = JSON.stringify({ name, ...data, url: location.pathname });
navigator.sendBeacon('/rum', body); // see self-hosted beacon collection
}
Why: A consistently positive gapMs at p75 proves hydration is gating your LCP — the largest element cannot paint its final state until React commits. A near-zero or negative gap means LCP is server-painted and hydration is hurting INP instead, not LCP. You need both signals to choose the right fix.
4. Convert blocking SSR into streaming SSR
If hydration is on the LCP path, the first lever is streaming. renderToPipeableStream flushes shell HTML immediately and streams Suspense boundaries as data resolves, so the LCP element can paint before the whole tree is ready.
import { renderToPipeableStream } from 'react-dom/server';
import App from './App';
app.get('*', (req, res) => {
const { pipe } = renderToPipeableStream(<App />, {
bootstrapModules: ['/client.js'],
onShellReady() {
// Flush as soon as the shell (above-the-fold, incl. LCP) is ready.
res.statusCode = 200;
res.setHeader('Content-Type', 'text/html');
pipe(res);
},
});
});
Why: Streaming decouples LCP paint from full-tree readiness, lowering both TTFB-to-first-paint and the time the LCP element waits behind slow data. It also enables selective hydration in the next step.
5. Apply selective and progressive hydration
Wrap non-critical, below-the-fold regions in Suspense. React 18 hydrates these lazily and prioritises the boundary the user interacts with first, shrinking the single blocking Long Task into smaller, interruptible chunks.
import { Suspense, lazy } from 'react';
const Comments = lazy(() => import('./Comments'));
const Recommendations = lazy(() => import('./Recommendations'));
export default function App() {
return (
<main>
<Hero /> {/* eager: contains the LCP element */}
<Suspense fallback={<Skeleton />}><Recommendations /></Suspense>
<Suspense fallback={<Skeleton />}><Comments /></Suspense>
</main>
);
}
Why: Each Suspense boundary becomes an independently hydratable unit. The hydration Long Task for the hero shrinks, the main thread yields between boundaries, and a click on Comments jumps that boundary to the front of the hydration queue — directly improving first-interaction INP.
6. Move static subtrees to React Server Components or islands
The cheapest hydration is none. Components that render no client state — marketing copy, the hero image, static headers — should never ship to the client. With RSC (Server Components), they render only on the server; with an islands architecture (Astro, or next/dynamic with ssr boundaries), only interactive islands hydrate.
// page.jsx — a Server Component by default in the Next.js App Router.
import LikeButton from './LikeButton'; // 'use client' — the only island.
export default function Page() {
return (
<article>
<h1>Static, server-only — zero hydration cost</h1>
<img src="/hero.avif" fetchpriority="high" alt="Hero" /> {/* LCP element */}
<LikeButton /> {/* the single hydrated island */}
</article>
);
}
Why: A static LCP image inside a Server Component has no hydration dependency at all — it paints from streamed HTML and stays painted. Shrinking the client bundle to interactive islands removes the JS that produced the hydration Long Task, so LCP renderTime and the first INP both drop. For the INP side of Next.js specifically, see Instrumenting INP in the Next.js App Router.
Verifying it works
- DevTools Performance panel: record a load with 4× CPU throttling. The
hydrationmeasure appears in the Timings track; confirm it shrinks and that the red Long Task bar under it narrows after each change. - Console check: run
performance.getEntriesByName('hydration', 'measure')[0].durationafter load. Compare before/after — a streaming + islands refactor commonly takes this from ~300 ms to under 80 ms on mid-tier mobile. - RUM dashboard signal: chart
gapMsat p75. The win condition isgapMstrending toward zero or negative (LCP no longer waiting on hydration) andblockingTimefalling, which you should see echoed in your INP field data.
| Signal | Source | Hydration is the problem when… | Target after fix (p75) |
|---|---|---|---|
hydration measure |
performance.measure |
duration > 200 ms on mid-tier mobile | < 80 ms |
gapMs (LCP − hydrationEnd) |
step 3 correlation | consistently positive | ≤ 0 ms |
| Hydration blocking time | longtask observer |
> 150 ms inside the window | < 50 ms |
| First-interaction INP | web-vitals onINP |
> 200 ms (Poor > 500 ms) | ≤ 200 ms (Good) |
Edge cases & gotchas
renderTimeis 0 for cross-origin LCP images. WithoutTiming-Allow-Origin,renderTimefalls back toloadTime, skewing the gap. Serve LCP images same-origin or set the header so step 3 stays accurate.requestIdleCallbackovershoots the real end. If the page is busy, idle time may not arrive for hundreds of milliseconds. For tighter bounds, markhydration:endin auseEffectat the root of the eagerly hydrated subtree instead of relying on idle.- Safari lacks
requestIdleCallbackandlongtask. Feature-detect both; fall back tosetTimeout(fn, 0)for the end mark and skip blocking-time attribution rather than throwing. Your hydrationmeasurestill works everywhere. - Hydration mismatches double the cost. A mismatch makes React discard server HTML and re-render client-side — the
hydration:mismatchmark from step 1 will spikegapMs. Fix the mismatch (oftenDate,Math.random, or locale formatting) before optimising anything else. - Selective hydration can reintroduce CLS. Lazy boundaries that swap a skeleton for taller content shift layout. Reserve space with
aspect-ratioor fixedmin-heightso you don’t trade hydration cost for a layout-shift regression.
FAQ
Does hydration always delay LCP?
No. If the LCP element is a server-rendered image or text block, it paints from streamed HTML before hydration runs, so hydration delays the first interaction (INP) rather than LCP. The gapMs correlation in step 3 tells you which case you are in — only a consistently positive gap means hydration is on the LCP path.
Why measure with performance.mark instead of just timing hydrateRoot?
hydrateRoot returns synchronously while React schedules the actual reconciliation work, so a Date.now() diff around the call measures almost nothing. A performance.measure anchored to a post-commit boundary (idle callback or root useEffect) captures the real reconciliation window and produces a named entry you can correlate and beacon.
How much can React Server Components reduce hydration cost?
It depends on how much of the tree is interactive, but moving static subtrees server-side removes their JS from the client bundle entirely, so their hydration cost goes to zero. Pages that are mostly content with a few interactive islands routinely cut total hydration blocking time by more than half.
Related
- Framework Performance Instrumentation — the parent guide to instrumenting React, Vue, and Next.js performance.
- Instrumenting INP in the Next.js App Router — attribute slow interactions to hydration and server-component boundaries.
- LCP Measurement & Optimization — the broader playbook for capturing and improving Largest Contentful Paint.