Skip to content
Chapter 30Lesson 5

Hydration and its mismatch failure modes

Hydration, the browser-side second render where React adopts the App Router's server HTML, and the small set of mismatch failures it throws when the two renders disagree, with the fix for each.

The invoice page renders perfectly on first paint. The total is right, the status badge is right, and at the bottom your MarkPaidButton shows “Paid 3 minutes ago”. Then the console turns red: “Hydration failed because the server rendered HTML didn’t match the client.” It worked locally, it worked yesterday, and now React refuses to take over a chunk of your page. By the end of this lesson you can read that error, name its cause from a short list, and apply the right fix. You will also tell a hydration bug from a server bug at a glance, the check that saves the most debugging time. The lesson on Client Components established that a Client Component runs twice and that both renders must agree; here you learn what happens when they don’t.

The server runs your Client Component to produce the initial HTML, ships that HTML alongside the RSC payload, and the browser paints it instantly. Then React in the browser re-runs the same component over that existing HTML to attach event listeners and take control of the DOM. That second pass is hydration .

Hydration adopts the existing HTML, it does not redraw it. React assumes the server’s markup is exactly what it would have produced, so it walks the DOM and wires up listeners in place. This is reconciliation , and during hydration the comparison is strict. If the browser render computes different output than the server baked into the HTML, React can’t line the two up, so it gives up on that subtree and throws the warning.

Why pay this price? Hydration is the cost of server-side rendering (SSR) . A client-rendered app ships a blank page and fills it in once JavaScript loads: no hydration, but a slow first paint. A static page has no interactivity, so nothing to hydrate. The App Router gives you instant server HTML and client interactivity, and hydration is the price, on one condition: your render must produce the same result on two different machines.

First, see which part of the page hydrates. The trace below follows one request for the invoice page through to the browser. Scrub from the server render to the hydrate phase and watch which node wakes up.

Which node hydrates?

MarkPaidButton, the one interactive leaf, is the only node that hydrates. That picture is also the answer to the diagnostic question this lesson closes with.

A mismatch means the two renders saw different inputs. Your component code is identical in both places, so the divergence is environmental. The server runs on one machine, at one instant, in one timezone, with no DOM; the browser runs on a different machine, a few hundred milliseconds later, in the user’s timezone, with a DOM that extensions may already have touched. Any value your render reads that depends on which machine or which instant it runs on comes out different: frozen into the server’s HTML, then contradicted on the client.

The set of things that can differ is small, and it splits into two buckets with different fixes.

The first bucket is your non-determinism: your code asked for a value that cannot be identical in both places.

  • Time. Date.now(), new Date().toLocaleTimeString(), every “3 minutes ago” label. The clock moves between the two renders.
  • Randomness. Math.random(), and any ID or key derived from it.
  • Locale and timezone formatting. The server formats a date as 1/6/2026 in UTC; the browser formats the same instant as 06/01/2026 in the user’s locale and zone.
  • Reading a browser-only source during render. typeof window !== 'undefined' ? a : b, localStorage.getItem(...), navigator.language. The window branch is impossible on the server and live in the browser, so the two renders differ by definition.

Every item here is a real bug in your render, and the rest of the lesson is about fixing them.

The second bucket is the browser’s noise: you didn’t write it, the user’s environment injected it. Extensions mutate the DOM before React hydrates. Grammarly adds data-gr-* attributes, password managers add data-1p-* or data-lpignore, Colorzilla adds cz-shortcut-listen. The server’s HTML never had them, but the DOM React finds does, so it throws the same error your own bugs do. Recognize it on sight, or you can lose an afternoon hunting your code for a divergence the extension caused.

You fix the first bucket and narrowly suppress the second; the fixes come next, one per cause. For now, just tell them apart.

Here is the whole problem in one picture: the same <span>Paid {when}</span> runs on two machines and produces two different strings.

Server render
UTC, one instant
<span>Paid {formatRelative(paidAt)}</span>
↓ renders to
Paid 3 minutes ago
Browser render
user’s zone, a beat later
<span>Paid {formatRelative(paidAt)}</span>
↓ renders to
Paid 2 minutes ago
Same code, two machines, two strings — React refuses to adopt the mismatch.

Now practice the skill the rest of the lesson rests on: reading a render expression and predicting whether the two machines agree.

Sort each expression by whether the server and the browser compute the same output. Drag each item into the bucket it belongs to, then press Check.

Same on server and client Both renders produce identical output
Differs between server and client The two renders disagree — a mismatch
<p>{invoice.total}</p>
<p>Updated {Date.now()}</p>
useId()
<p>{Math.random()}</p>
<p>{user.name}</p>
new Intl.NumberFormat().format(total)
<time>{isoString}</time>

Two items look like traps, useId() and the Intl formatter, and the sections ahead explain both. Every “differs” item is non-deterministic , and that is exactly what breaks the handshake. React names the failure a hydration mismatch in its error string.

Defer non-deterministic values to useEffect

Section titled “Defer non-deterministic values to useEffect”

Reach for this first; it’s the right answer most of the time. The principle: if a value can’t match on both machines, don’t render it during the first render at all. Render a deterministic placeholder, then swap in the real value after hydration, inside a useEffect.

It works because of when useEffect runs. It never runs on the server, and in the browser it runs only after the component mounts, which is after hydration finishes. So a value an effect sets is never in the server HTML and never part of the hydration comparison. Both renders agree on the placeholder, and a client-only re-render paints the real value a tick later.

Here it is on the invoice’s "Paid {relative time}" leaf beside MarkPaidButton, before and after.

app/invoices/_components/paid-label.tsx
'use client';
export const PaidLabel = ({ paidAt }: { paidAt: Date }) => {
return <span>Paid {formatRelative(paidAt)}</span>;
};

Throws on hydration. The relative string is computed at render time, so it differs by the round-trip latency between the two renders. This is the anti-pattern, not code to ship.

Here useEffect is doing its legitimate job: synchronizing with something outside React, such as wall-clock time or the browser’s locale. This is not the deriving-state or fetching-data misuse you were warned off earlier, because the wall clock really is an external system the component must sync with.

The first paint shows the fallback, so pick one that reads fine on its own: an absolute timestamp, a skeleton, or a dash. Never ship a fallback that looks broken, because the user sees it for a real moment before the effect swaps in the live value.

The second fix has one cause. Sometimes you need a unique ID string to wire two elements together: a <label htmlFor> pointing at an <input id>, or an aria-describedby pointing at a hint element. The obvious ways to generate that ID are all non-deterministic. Math.random() returns a different number on each machine, a module-level counter increments in a different order on the server than in the browser, and crypto.randomUUID() is random by definition. Each produces a different id attribute on the two sides, and a different attribute is a mismatch.

useId derives a stable, unique string from the component’s position in the render tree instead of from any random source. The server and the browser walk the same tree, so they compute the same ID for the same element.

app/invoices/_components/amount-field.tsx
'use client';
export const AmountField = () => {
const id = useId();
return (
<div>
<label htmlFor={id}>Amount</label>
<input id={id} aria-describedby={`${id}-hint`} />
<p id={`${id}-hint`}>Whole dollars, no symbol.</p>
</div>
);
};

You will meet useId again with forms and accessibility; here it is simply the hydration-safe source of IDs. The rule is short: never use a random number or a counter to make an ID inside a Client Component subtree.

When you can’t defer: suppressHydrationWarning

Section titled “When you can’t defer: suppressHydrationWarning”

The last fix is an escape hatch, and it comes last because it is easy to misuse. suppressHydrationWarning is a boolean prop on a single element. It tells React to skip the mismatch check for that element’s own text and attributes only, not its children and not the rest of the tree. One element, one level deep.

Reach for it in exactly two cases. The first is a value that is correctly different by design and that you don’t want to defer for UX reasons, such as a timestamp that must paint immediately and is allowed to be a second off. Put suppressHydrationWarning on that <time>, let the post-hydration render correct it, and the user never sees a flash. The second is the extension noise from the second bucket. Extensions mutate the document <body>, so the suppression goes there, and a stray data-gr-* or cz-shortcut-listen no longer trips the warning for your whole app.

One subtlety: you have already used this prop on a different element. In the theme-switching lesson you put suppressHydrationWarning on <html>, because the theme script sets a class there before React hydrates and that intentional difference would otherwise trip the warning. The extension case puts the same prop on <body>. Same prop, two elements, two unrelated reasons, so when you see <html suppressHydrationWarning> in a root layout, read it as the theme script, not extension noise.

app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
// The theme script sets `class` on <html> before React hydrates.
<html lang="en" suppressHydrationWarning>
{/* Browser extensions inject attributes onto <body> before hydration. */}
<body suppressHydrationWarning>{children}</body>
</html>
);
}

The limit is what keeps the prop honest: it silences one element, one level deep, and every child below it still hydrates strictly. It is not a way to quiet a mismatch you don’t understand. Adding it to make a useEffect-shaped bug disappear hides a real divergence that will resurface somewhere worse. Reach for useEffect or useId first, and use suppressHydrationWarning only for values that are correctly different by design plus the body-level extension noise.

You now have all three fixes. Match each scenario to the right one.

Pick the right fix for each hydration mismatch. Pick the right option from each dropdown, then press Check.

An invoice card shows a relative Date.now()-based “Paid N minutes ago” label that disagrees by the round-trip latency. Fix: .

A form field generates a fresh htmlFor / id pair to wire its label to its input, and the two machines mint different strings. Fix: .

Grammarly injects data-gr-* attributes onto the page before React hydrates, and the server HTML never had them. Fix: .

A list uses Math.random() as each item’s key, so every render computes a different one. Fix: .

Two checks save the most time, and you run both before touching render code.

Is it even a hydration bug? Only Client Components hydrate. A Server Component ships HTML plus reconciliation data and never runs again in the browser, so it cannot throw a hydration mismatch. That gives you a one-glance test: open the file the error points at and look for "use client" at the top, either directly or in a file it imports. If no client boundary sits above it, the bug is in your server render, such as wrong data, a thrown error, or a bad await, and none of this lesson’s fixes apply. This is the picture from the trace at the top: the interactive leaf hydrates, the rest of the tree doesn’t, so the rest of the tree can’t be the source.

The stale .next cache. Sometimes the error points at HTML you cannot find anywhere in your source. Before you doubt your own eyes, suspect the dev build cache: .next sometimes serves stale HTML from before your last edit, so the mismatch is against markup you already deleted. When the error makes no sense against the current code, clear it.

Terminal window
rm -rf .next
pnpm dev

This is a tooling quirk, not a concept, but naming it spares you an hour chasing a bug that was never in your code.

A teammate adds suppressHydrationWarning to the root <html> to silence a “Paid 2 minutes ago” mismatch coming from deep inside an invoice card. What’s wrong with this fix?

The prop guards only one element’s own content, one level deep, so on <html> it can’t reach a mismatch buried in a card — and even where it lands, a deferrable timestamp shouldn’t be suppressed at all.
Nothing — putting suppressHydrationWarning on <html> is the standard way to silence any timestamp mismatch.
It will break the dark-mode theme script, which also relies on that same prop on <html>.
suppressHydrationWarning cascades to every descendant, so it will also hide genuine bugs elsewhere on the page.