Hydration: How It Works and How to Debug Mismatches

Understand what hydration does step by step, what a hydration mismatch is and its most common causes, how to design hydration-safe code (including IDs), and how to systematically debug a mismatch when one occurs.

Hydration is the moment where server-rendered (or statically generated) HTML — which is inert, just markup sitting in the DOM — becomes a live React app. React walks the existing DOM and its own freshly-computed render output side by side, attaches event listeners and internal state as it goes, and (ideally) never has to touch the DOM structure itself because the two already match. When they don't match, you get a hydration mismatch, one of the most common and most confusing bugs in server-rendered React apps.

What hydration does, step by step

Before hydration, the browser has plain HTML — sent by the server (SSR) or served from a static file (SSG) — sitting in the DOM. It looks like a working page, but no event listeners are attached: clicking a button does nothing, because there is no React runtime wired to that DOM yet.

When the client-side JavaScript bundle loads, React runs your component tree again — the exact same components, computing what the DOM 'should' look like — but this time, instead of creating new DOM nodes for that output (as it would in a plain CSR mount), it walks the *existing* DOM node by node and compares what's already there against what it just computed.

For every node that matches, React reuses the existing DOM element outright and attaches its event handlers and fiber (internal bookkeeping) to it. No DOM mutation happens for matching content — this is what makes hydration cheaper than a full re-render. Once the whole tree has been walked and matched, the page is now a live, interactive React app: state updates, effects, and event handlers all work normally from that point on.

What a hydration mismatch is and its common causes

A hydration mismatch happens when the HTML React computes on the client during hydration doesn't match the HTML that was actually sent by the server. React detects the discrepancy (usually a text content, attribute, or element-type difference), logs a warning, and then has to recover by discarding the mismatched DOM subtree and re-rendering it client-side from scratch — which both hurts performance (you lose hydration's reuse benefit for that part of the tree) and can cause a visible flash as the content changes.

The most common causes all boil down to the same root issue: something that differs between the environment and moment the server rendered in, and the environment and moment the client re-renders in during hydration. Classic culprits include: calling Math.random() or generating a random ID directly in render output (different value each time it runs); using Date.now() or new Date() formatted with the visitor's local timezone (server and client machines can disagree, and even the same machine's server-render and client hydration happen at slightly different instants); and branching render output on typeof window !== "undefined" (true on the client, always false on the server, so the two renders produce structurally different output by design).

Other common causes: browser extensions that inject markup into the DOM before React hydrates (React sees extra nodes it didn't render and didn't expect); invalid HTML nesting that the browser silently 'fixes' while parsing the server's HTML (for example a <div> nested inside a <p>, which browsers auto-close differently than React expects), which makes the actual DOM structure differ from what the server thought it sent; and locale-dependent number/date formatting that resolves differently on a server (which may run in a default locale/timezone) versus the visitor's browser.

Hydration-safe patterns, including IDs

The general fix for anything that legitimately needs to differ between server and client is: render a stable, identical placeholder on both the server render and the client's *first* render pass (so hydration matches cleanly), and then update it to the 'real', environment-specific value inside a useEffect — which only ever runs in the browser, after hydration has already completed successfully.

IDs deserve special mention because they're an extremely common source of mismatches: generating a random or counter-based ID (for example, for linking a <label> to an <input> via htmlFor/id) with something like Math.random() produces a different ID on the server versus the client hydration pass. React's built-in useId() hook exists specifically to solve this — it generates a stable, deterministic identifier that is guaranteed to match between the server render and the client hydration render for the same component tree position, making it hydration-safe by design.

It's worth noting explicitly: hydration itself only exists if something is rendering your React tree on the server or at build time first — SSR or SSG, both provided by a framework, not by plain Vite + React. A plain Vite CSR app has nothing to hydrate: React mounts fresh into an empty <div id="root">, so hydration mismatches as a category simply don't apply to it (though the same 'differs between renders' bugs can still cause stale/inconsistent UI in a CSR context, just without the hydration warning).

Debugging a hydration mismatch, systematically

React's hydration mismatch warning in development typically tells you which DOM node it encountered a difference at and often shows a diff between the server-rendered HTML and the client-rendered output — read this diff carefully first, since it usually points directly at the offending element or text.

A reliable debugging technique: use 'view page source' (or fetch the raw HTML response, not the live DOM inspector) to see exactly what the server actually sent, and compare it against what the rendered DOM looks like right after hydration completes. Any difference between those two is your mismatch. This is more reliable than eyeballing the component code, because it shows you the ground truth of both renders rather than what you assume they'll produce.

Once you've located the differing node, work backward to why: is it reading Date.now(), Math.random(), or a non-deterministic ID generator directly in render? Is it branching on typeof window? Is it reading from localStorage or a cookie that's only available on one side? Is invalid HTML nesting causing the browser to restructure the server's markup before React ever sees it? Systematically ruling these out, one at a time, converges quickly on the actual cause — trying to guess the fix without first confirming exactly which node and value differ usually wastes more time than it saves.

A hydration-safe pattern for client-only values, and useId

Timestamp renders the identical placeholder ("—") on the server and on the client's initial hydration pass, so there's no mismatch — the real time is filled in only after hydration, inside useEffect. useId() replaces a hand-rolled random or counter-based ID generator; React guarantees the same id string is produced during the server render and the matching client hydration render.

import { useState, useEffect, useId } from "react";

function Timestamp() {
  const [now, setNow] = useState<string | null>(null); // same on server & first client render: null

  useEffect(() => {
    setNow(new Date().toLocaleTimeString()); // only runs in the browser, after hydration
  }, []);

  return <span>{now ?? "—"}</span>;
}

function LabeledInput({ label }: { label: string }) {
  const id = useId(); // stable, deterministic — guaranteed to match server and client
  return (
    <>
      <label htmlFor={id}>{label}</label>
      <input id={id} type="text" />
    </>
  );
}

Code Examples

The server computes new Date().toLocaleTimeString() at request/build time; the client computes it again, independently, during hydration — a moment later, and potentially in a different timezone. The two strings almost never match, producing a hydration mismatch on the <p> text content.

Common mistake: Any value that can legitimately differ between the server's render pass and the client's hydration render pass — current time, random values, browser-only globals, locale-dependent formatting — will cause a mismatch if rendered directly instead of being deferred to a post-hydration useEffect.

The warning itself names the exact mismatched text, but confirming it against the raw server HTML (view-source) and the post-hydration DOM separates 'what the server actually sent' from 'what the client recomputed', which is the fastest way to pin down whether the bug is in the render logic (as here) versus, say, invalid HTML nesting that the browser silently restructured.

new Date().toLocaleTimeString() // called directly in render — recomputed independently on each side

function Greeting() {
  return <p>Rendered at {new Date().toLocaleTimeString()}</p>; // BAD
}
// Console warning (abbreviated):
// Warning: Text content did not match. Server: "Rendered at 10:42:03 AM"
// Client: "Rendered at 10:42:04 AM"

// Step 1: view-source shows the server actually sent "10:42:03 AM" -> confirms server output.
// Step 2: the live DOM right after hydration shows "10:42:04 AM" -> confirms client recomputed it.
// Step 3: locate the offending code: new Date().toLocaleTimeString() called directly in render.
// Step 4: fix -> render a stable placeholder, move the real value into useEffect (see syntax block above).

Hydration matching

Hydration is fundamentally a node-by-node comparison between what the server sent and what the client independently computes — any difference breaks the reuse and forces a client-side re-render of that subtree.

Server renders:  <p>Hello</p>
        |
  sent as HTML to browser
        |
Client renders same tree during hydration:  <p>Hello</p>
        |
   Match? YES -> reuse existing DOM node, attach listeners, done
   Match? NO  -> mismatch warning, discard + re-render that subtree client-side

Important Rules

Hydration compares the client's freshly computed render output against the existing server-rendered DOM, node by node, reusing matching nodes and attaching listeners without recreating them.

Never call Date.now(), Math.random(), or generate non-deterministic IDs directly in render output for anything that will be server-rendered — these differ between the server pass and the client hydration pass.

Never branch render output on typeof window !== "undefined" (or similar environment checks) — it guarantees the server and client outputs are structurally different by construction.

For values that must differ after mount, render an identical, stable placeholder on both the server render and the client's first render, then update it inside useEffect, which only runs in the browser after hydration.

Use useId() for any id that needs to match between elements (like a <label htmlFor> / <input id> pair) instead of hand-rolling a random or counter-based id — it's guaranteed to match between server and client.

To debug a mismatch, compare the raw server HTML (view-source) against the post-hydration DOM to confirm exactly which node and value differ, before guessing at a fix.

Hydration mismatches only exist where something is server-rendering or statically pre-rendering the tree first (SSR/SSG, both framework-provided); a plain Vite CSR mount has no hydration step at all.

Where this shows up

Any SSR or SSG React app (via a meta-framework) hits hydration mismatches most often around 'relative time' displays ('2 minutes ago', which changes with the current instant), locale-dependent date/number formatting, feature flags or A/B test branches read from client-only storage, and third-party browser extensions (ad blockers, password managers) that inject DOM nodes before React hydrates — none of which are bugs in React itself, but all of which violate hydration's core requirement that the server and client render the same output.

<p>{typeof window !== "undefined" ? "Client" : "Server"}</p>

Render a stable value on both server and client's first render (e.g. always "Server" or a shared placeholder), then update it inside useEffect if it truly needs to change once mounted in the browser.

Branching render output directly on an environment check guarantees the server and client renders produce different output — exactly the condition that causes a hydration mismatch, by design rather than by accident.

const id = input-${Math.floor(Math.random() * 100000)}; // used for htmlFor/id const id = useId(); // deterministic, guaranteed to match server and client renders

A randomly generated id is recomputed independently by the server render and the client hydration render, so they virtually never match, producing a mismatch on the id attribute (and breaking the label/input association even before hydration runs, since the server's own HTML pairing is internally consistent but the client then overwrites it with a different pair).

[multiple-choice] What does React do when it encounters a hydration mismatch? A. It silently ignores the difference and keeps the server's HTML forever B. It crashes the app and refuses to render C. It logs a warning and discards/re-renders the mismatched subtree client-side, losing the reuse benefit of hydration for that part D. It automatically fixes the server's rendering logic

C. It logs a warning and discards/re-renders the mismatched subtree client-side, losing the reuse benefit of hydration for that part — On a mismatch, React logs a warning describing the difference and then recovers by throwing away the affected DOM subtree and re-rendering it on the client, which works functionally but loses hydration's performance benefit (and can cause a visible flash) for that part of the page.

[multiple-choice] Which hook exists specifically to generate ids that are guaranteed to match between server rendering and client hydration? A. useRef B. useMemo C. useId D. useLayoutEffect

C. useId — useId() generates a stable, deterministic identifier tied to the component's position in the tree, guaranteed to produce the same value during the server render and the corresponding client hydration render — unlike Math.random() or a manual counter.

[true-false] A plain Vite + React app (CSR only, no SSR/SSG) can experience hydration mismatches. A. True B. False

B. False — False. Hydration only happens when React attaches to DOM that was already rendered by a server or at build time (SSR/SSG). A plain Vite CSR app mounts React into an empty root div with nothing pre-rendered, so there is no hydration step and therefore no hydration mismatch to encounter.

A component renders a unique input id using id={String(Math.random())} and also renders <p>{navigator.userAgent}</p> directly. Rewrite it to be hydration-safe.

function SafeForm() { const id = useId(); const [ua, setUa] = useState<string | null>(null); useEffect(() => { setUa(navigator.userAgent); }, []); return ( <div> <label htmlFor={id}>Name</label> <input id={id} type="text" /> <p>{ua ?? "—"}</p> </div> ); }

The id needs useId(). The navigator.userAgent read needs to move into useEffect, with a stable placeholder shown until then.