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.

Hydration overlap with LCP on the main-thread timeline A timeline where streamed HTML paints early, a hydration long task blocks the main thread, and the LCP element renders after hydration finishes, inflating both LCP and the first INP. 0 ms time SSR HTML paints early hydrateRoot (Long Task) main thread blocked LCP renders after hydration Hydration sits between first paint and LCP first input ignored here
When the LCP element renders after the hydration Long Task, hydration cost is baked into both LCP and the first interaction's INP. Aggregate the gap at p75 across your field data.

Prerequisites

  • A React 18+ app using hydrateRoot (or a framework wrapping it: Next.js, Remix, custom SSR). The react-dom/server streaming API (renderToPipeableStream) is what unlocks the reduction strategies later.
  • The web-vitals library 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 hydration measure 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].duration after 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 gapMs at p75. The win condition is gapMs trending toward zero or negative (LCP no longer waiting on hydration) and blockingTime falling, 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

  • renderTime is 0 for cross-origin LCP images. Without Timing-Allow-Origin, renderTime falls back to loadTime, skewing the gap. Serve LCP images same-origin or set the header so step 3 stays accurate.
  • requestIdleCallback overshoots the real end. If the page is busy, idle time may not arrive for hundreds of milliseconds. For tighter bounds, mark hydration:end in a useEffect at the root of the eagerly hydrated subtree instead of relying on idle.
  • Safari lacks requestIdleCallback and longtask. Feature-detect both; fall back to setTimeout(fn, 0) for the end mark and skip blocking-time attribution rather than throwing. Your hydration measure still works everywhere.
  • Hydration mismatches double the cost. A mismatch makes React discard server HTML and re-render client-side — the hydration:mismatch mark from step 1 will spike gapMs. Fix the mismatch (often Date, 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-ratio or fixed min-height so 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.