open atlas
↑ Back to track
Next.js, zero to senior NEXT · 01 · 03

Hydration: matching the server's HTML, and paying for the JS you ship

Hydration attaches listeners to server HTML — markup must match the client render, or React recovers with a costly full client re-render. Date.now, locale, browser-only branches cause mismatches; Suspense enables selective hydration; less shipped JS = better INP/TBT.

NEXT Senior ◷ 18 min
Level
FoundationsJuniorMiddleSenior

The bug report said: “Order timestamps flicker on load, and sometimes the whole page repaints.” Locally — nothing. In production — Sentry full of “Hydration failed because the server rendered HTML didn’t match the client.” The line at fault looked innocent: <span>{formatRelative(order.createdAt, Date.now())}</span>. The server rendered “2 minutes ago” at 14:03:07; by the time the client hydrated on a mid-range phone over 4G, it was 14:03:11 and the client computed “3 minutes ago”. Two honest renders, two different answers. React 18 doesn’t patch over that: on a mismatch it throws away the server-rendered DOM for that root and re-renders entirely on the client — the user sees the repaint, the phone pays for rendering the page twice, and the analytics team asks why INP regressed on the orders page. A second report came in from Frankfurt: prices mismatching because the server formatted 1.234,56 € with a German ICU locale and the user’s browser formatted €1,234.56. Same class of bug: any input that differs between the server’s render and the client’s first render is a hydration mismatch waiting for traffic.

After this lesson, mismatches in production will stop looking like magic — you’ll know the three inputs that cause them, how to fix each, and how to measure the JS cost you’re actually paying.

What hydration actually does

Server-rendered HTML is paint, not behavior: the user sees a complete page, but no listener is attached, no state exists, refs are empty. Hydration is how React brings it to life: hydrateRoot(domNode, reactNode) renders the component tree again on the client — but instead of creating DOM, it walks the existing server-produced DOM, adopts each node into its fiber tree, attaches event handlers, and initializes state and effects. It is a render whose output is reconciliation against markup that already exists.

This design has one hard precondition, stated bluntly in React’s docs: the React tree you pass to hydrateRoot must produce the same output the server produced. React does not diff-and-patch differences during hydration; matching is an assumption baked into the algorithm, not a feature it provides. Text content differences get patched in development with a warning, but structural or attribute mismatches in React 18 trigger recovery: React abandons the server HTML for the affected boundary and performs a full client-side render to replace it. The user-visible cost is a flash of replaced content; the performance cost is that your mid-range-device user just rendered the page twice — once in HTML they couldn’t interact with, once in JS that blocked the main thread.

// The three classic mismatch factories — all are "two honest renders, two answers":

// 1. Time: server renders at T0, client hydrates at T0 + network + parse + execute.
<span>{formatRelative(order.createdAt, Date.now())}</span>

// 2. Locale/environment: Node's ICU vs the user's browser settings.
<td>{price.toLocaleString()}</td>

// 3. Browser-only branches: there is no window during the server render.
<div>{typeof window !== "undefined" ? <LocalStorageGreeting /> : <DefaultGreeting />}</div>

// The fix shape is always the same: first client render must equal the server render.
function ClientOnly({ children }: { children: React.ReactNode }) {
  const [mounted, setMounted] = useState(false);
  useEffect(() => setMounted(true), []);   // effects run only after hydration
  return mounted ? <>{children}</> : null; // both server and first client render: null
}

The fix pattern deserves emphasis because it looks paradoxical: to render browser-dependent content, you render nothing (or a placeholder) on both the server and the first client pass, then switch after useEffect fires — effects only run post-hydration, so the switch is an ordinary update, not a mismatch. For a single unavoidable divergence (a timestamp the server must render), suppressHydrationWarning on that element tells React to leave that one text difference alone — a scalpel, not a policy. Random IDs are solved properly with useId, which is hydration-stable by construction.

Quiz

In React 18, a component renders `Math.random()` into the DOM. Server output: 0.42. Client hydration computes 0.87. What does React do?

Selective hydration: Suspense as a hydration boundary

Before React 18, hydration was monolithic: one synchronous pass over the whole tree, and nothing was interactive until everything was. The page couldn’t even begin hydrating until all its JS arrived, and a heavy component high in the tree held every sibling hostage. React 18’s selective hydration breaks the monolith using <Suspense>: each Suspense boundary becomes an independently hydratable island. Hydration proceeds boundary by boundary, in chunks that yield to the main thread — and it is priority-driven: if the user clicks inside a not-yet-hydrated boundary, React records the event and synchronously hydrates that boundary first, jumping the queue, then replays the interaction. The heavy comments widget at the bottom no longer delays the buy button at the top.

This composes with streaming SSR: a server component that suspends (slow data) streams its HTML later, and hydrates when it arrives, while the shell is already interactive. The mental model shifts from “the page hydrates” to “boundaries hydrate, in the order users need them.”

export default function ProductPage() {
  return (
    <main>
      <BuyBox />                       {/* critical: hydrates first */}
      <Suspense fallback={<Skeleton />}>
        <Reviews />                    {/* heavy: streams + hydrates later */}
      </Suspense>
      <Suspense fallback={<Skeleton />}>
        <Recommendations />            {/* user clicks here? -> queue-jumps */}
      </Suspense>
    </main>
  );
}

A second property matters operationally: in React 18, a hydration mismatch inside a Suspense boundary recovers at that boundary alone — the client re-render is scoped to the island instead of cascading to the root. Boundaries are blast-radius walls for mismatch recovery, not just loading states.

Islands, INP, and the JS you didn’t ship

Before you optimize how fast your app hydrates, ask a more useful question: how much of what you’re hydrating actually needs to run in the browser? The answer, in most apps, is far less than what’s currently shipping.

Hydration cost is roughly proportional to the JS you ship: every shipped component must be downloaded, parsed, executed, and reconciled against the DOM before it’s truly interactive. That work lands on the main thread and shows up in two field metrics. TBT (Total Blocking Time, lab) accumulates every long-task slice over 50ms during load — monolithic hydration of a big tree is exactly such a task, often 1–3 seconds of blocking on a mid-range Android device for a few hundred KB of components. INP (Interaction to Next Paint, a Core Web Vital) measures real interaction latency; the threshold for “good” is 200ms, and a tap that lands mid-hydration waits for the main thread before anything paints. The page looks ready — HTML painted seconds ago — but pokes like a dead thing: that gap between painted and interactive is precisely the hydration window, and users spend it rage-clicking.

The architectural response across the ecosystem is islands of interactivity: server-render everything, hydrate only the components that genuinely need JS. Next.js expresses islands through the RSC boundary — server components ship zero JS and never hydrate; only "use client" subtrees do. Astro expresses it as per-island directives; Qwik goes further and resumes serialized state instead of replaying renders. The engineering lever is the same everywhere and it’s the one you control directly: the fastest hydration is the hydration you don’t ship. Moving a formatter, a static chart, or a markdown body from a client component to a server component doesn’t make hydration faster — it makes that subtree’s hydration cease to exist, and TBT/INP improve in direct proportion.

Why this works

Why is recovery (re-render from scratch) React’s mismatch strategy, instead of diffing the real DOM against the expected tree and patching? Because hydration’s performance contract is built on not doing that work. Adopting nodes by position with the assumption of equality is a single cheap walk; verifying every attribute and text node against the virtual tree would make every hydration pay the price of the rare broken one. React chooses: trust, and if trust is violated, rebuild the boundary — making correct pages fast and incorrect pages visibly wrong (warning + repaint) so the bug gets fixed at the source. It’s the same engineering posture as optimistic concurrency: don’t pay for conflict detection on every operation; pay only, and loudly, when a conflict actually happens.

Quiz

A product page's INP is 450ms on mid-range phones; traces show taps landing during hydration of a large client-component tree. Which change attacks the root cause?

Recall before you leave
  1. 01
    What causes hydration mismatches, what does React 18 do when one occurs, and what is the canonical fix pattern?
  2. 02
    How does selective hydration work, and why does shipping less JS improve INP/TBT more than optimizing hydration?
Recap

Server HTML is paint without behavior; hydrateRoot brings it to life by rendering the component tree on the client and adopting the existing DOM — attaching listeners, initializing state — under one hard precondition: client output must equal server output. React does not diff-and-patch during hydration; equality is the algorithm’s assumption. The mismatch factories are inputs that honestly differ across the two renders: Date.now (the client hydrates seconds after the server rendered), locale and ICU differences between Node and the user’s browser, browser-only branches on typeof window, randomness. In React 18 a mismatch triggers recovery: the server HTML for the affected boundary is discarded and re-rendered fully on the client — a visible flash and a double render on the user’s device, which is why mismatches are performance bugs, not cosmetic warnings. The canonical fix renders identical output on the server and the first client pass (null or placeholder), then switches inside useEffect — effects run post-hydration, so the swap is an ordinary update; suppressHydrationWarning is the scalpel for a single unavoidable divergence, and useId generates hydration-stable IDs. Suspense turns monolithic hydration into selective hydration: each boundary hydrates independently in chunks that yield to the main thread, late-streamed HTML hydrates on arrival, a click inside an unhydrated boundary queue-jumps and replays, and mismatch recovery is scoped to the boundary. The cost model: hydration work is proportional to shipped JS and lands on the main thread as TBT during load and degraded INP (good means under 200ms) for early taps — the painted-but-dead window users rage-click through. Islands thinking closes it: server components never hydrate, so moving non-interactive subtrees out of client components doesn’t speed hydration up — it deletes it. Now when you see INP regressions or mismatch errors in Sentry, you’ll know whether to reach for useEffect, suppressHydrationWarning, a server component, or a <Suspense> boundary — each fixes a different root cause.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 6 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

Apply this

Put this lesson to work on a real build.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.