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.
Hydration adopts the server HTML
Section titled “Hydration adopts the server HTML”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.
Every component runs on the server first to produce HTML, the MarkPaidButton
client leaf included. "use client" does not mean “skip the server”; it marks
where hydration will later attach.
Only MarkPaidButton wakes up here and attaches its click listener.
InvoiceList shipped zero client JS, so there is nothing to wake.
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.
Why server and client renders disagree
Section titled “Why server and client renders disagree”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 orkeyderived from it. - Locale and timezone formatting. The server formats a date as
1/6/2026in UTC; the browser formats the same instant as06/01/2026in the user’s locale and zone. - Reading a browser-only source during render.
typeof window !== 'undefined' ? a : b,localStorage.getItem(...),navigator.language. Thewindowbranch 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.
<span>Paid {formatRelative(paidAt)}</span> <span>Paid {formatRelative(paidAt)}</span> 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.
<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.
'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.
'use client';
export const PaidLabel = ({ paidAt }: { paidAt: Date }) => { const [label, setLabel] = useState<string | null>(null);
useEffect(() => { setLabel(formatRelative(paidAt)); }, [paidAt]);
return <span>Paid {label ?? formatAbsolute(paidAt)}</span>;};Server and first client render both produce the stable absolute fallback. The relative label appears a beat later, client-side only, so the two renders never disagree.
'use client';
export const SavedFilter = () => { const [isMounted, setIsMounted] = useState(false);
useEffect(() => { setIsMounted(true); }, []);
if (!isMounted) return <FilterSkeleton />; return <FilterBar initial={localStorage.getItem('invoice-filter')} />;};The same fix, generalized. When a whole subtree has to wait for the browser, here to read localStorage for a saved filter, gate it behind an isMounted flag and render a skeleton until the effect flips it.
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.
Generate stable IDs with useId
Section titled “Generate stable IDs with useId”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.
'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.
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 before you change render code
Section titled “Two checks before you change render code”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.
rm -rf .nextpnpm devThis 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?
<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.suppressHydrationWarning on <html> is the standard way to silence any timestamp mismatch.<html>.suppressHydrationWarning cascades to every descendant, so it will also hide genuine bugs elsewhere on the page.suppressHydrationWarning covers a single element’s own text and attributes, one level deep — on <html> it does nothing for a mismatch several levels down in a card, and even if it landed on the right element, suppressing is the wrong tool for a value you can simply defer. The correct fix is to move the relative timestamp into a useEffect and render a stable fallback (an absolute date or a dash) until it mounts. Note it does not cascade to children — that’s the misconception in the last option.