Hydration and its mismatch failure modes
Hydration, the browser-side second render where React adopts the App Router's server HTML, and the small closed set of mismatch failures it throws when the two renders disagree, plus the fixes 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”, a relative timestamp that feels like a nice touch. 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 is refusing 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 be able to tell a hydration bug from a server bug at a glance, which is the check that saves the most debugging time. In the lesson on Client Components you learned that a Client Component runs twice and that both renders must agree. This lesson covers what happens when they don’t, and what to do about it.
The two renders have to agree
Section titled “The two renders have to agree”Start from the model you already have. 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 .
The detail that drives this whole lesson is that hydration adopts the existing HTML, it does not redraw it. React assumes the markup the server sent is exactly what it would have produced itself, so it walks the DOM and wires up listeners in place. This is the reconciliation step, 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 you just saw.
So why pay this price at all? Because hydration is the cost of server-side rendering (SSR) . A purely client-rendered app ships a blank page and fills it in once JavaScript loads: no hydration, but a slow first paint. A purely static page has no interactivity, so it has nothing to hydrate either. The App Router hands you instant server HTML and client interactivity, and hydration is what you pay for getting both. That deal comes with one condition: your render has to produce the same result on two different machines.
Before we break that rule, let’s see exactly which part of the page hydrates. The trace below follows one request for the invoice page through to the browser. Scrub it 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 and never hydrates, so there is nothing to wake.
MarkPaidButton, the one interactive leaf, is the only node that hydrates. Hold onto that picture, because it is also the answer to the diagnostic question we close the lesson with.
Why the two renders diverge
Section titled “Why the two renders diverge”At its core, a mismatch means the two renders saw different inputs. Your component code is byte-for-byte identical in both places, so the divergence is not in the code. What differs is the environment. The server is one machine, at one instant, in one timezone, with no DOM. The browser is a different machine, a few hundred milliseconds later, in the user’s timezone, with a DOM that browser extensions may already have touched. Any value your render reads that depends on which machine or which instant it runs on will come out different. That difference gets frozen into the HTML on the server, then contradicted on the client.
That sounds open-ended, but the set of things that can differ is small and fixed. It splits cleanly into two buckets, and the split matters because each bucket has a different fix.
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(), and every “3 minutes ago” relative label. The clock has moved between the server render and the browser render, so the strings disagree. - Randomness.
Math.random(), and any ID orkeyderived from it. Two evaluations, two numbers. - Locale and timezone formatting. The server formats a date as
1/6/2026in UTC; the browser formats the same date as06/01/2026in the user’s locale and zone. Same instant, different rendering. - Reading a browser-only source during render.
typeof window !== 'undefined' ? a : b,localStorage.getItem(...),navigator.language. On the server thewindowbranch is impossible, so it renders one thing; in the browser it renders another. They differ by definition.
You own this bucket. Every item in it 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. Browser extensions mutate the DOM before React gets a chance to hydrate. Grammarly adds data-gr-* attributes to editable text. Password managers add data-1p-* or data-lpignore. Colorzilla adds cz-shortcut-listen. The HTML the server sent never had those attributes, but the DOM React finds in the browser does. This is not your bug, yet it throws the exact same error. If you don’t recognize it on sight, you can lose an afternoon searching your own code for a divergence the extension caused.
The two buckets call for different responses: you fix the first, and you acknowledge and narrowly suppress the second. For now, the goal is just to tell them apart. The fixes come next, one per cause.
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: looking at a render expression and predicting whether the two machines will agree. Sort each expression into the right column.
Sort each render expression by whether the server and the browser compute the same output for it. 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. What every “differs” item has in common is that its value is non-deterministic : it depends on something outside the code, so two evaluations can disagree. That is exactly what breaks the handshake, and React names the failure in its error string. It calls it a hydration mismatch .
Fix it after mount: the useEffect deferral
Section titled “Fix it after mount: the useEffect deferral”Reach for this one first, because it’s the right answer most of the time. The principle is simple: if a value can’t be the same on both machines, don’t render it during the first render at all. Render a deterministic placeholder instead, then swap in the real value after hydration, inside a useEffect.
This works because of when useEffect runs. It never runs on the server, and in the browser it runs only after the component has mounted, which is after hydration is already done. So a value an effect sets is never part of the server HTML and never part of the strict hydration comparison. Both renders agree on the placeholder, React adopts the DOM cleanly, and then a client-only re-render paints the real value a tick later.
Here it is on the invoice’s "Paid {relative time}" leaf that sits 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>;};The server and the 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 an entire subtree has to wait for the browser (here it reads localStorage for a saved filter), gate it behind an isMounted flag and render a skeleton until the effect flips it.
Notice what useEffect is doing here, because it’s the legitimate use you already know: synchronizing with something outside React, such as wall-clock time or the browser’s locale. This is not the deriving-state-in-an-effect or fetching-data-in-an-effect misuse you were warned off earlier, because the wall clock genuinely is an external system the component has to sync with.
The first paint shows the fallback, so pick a fallback that reads fine on its own. An absolute timestamp, a skeleton, or a dash all work. 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 rule to remember: when a value is non-deterministic, defer it to useEffect and render a stable placeholder first.
Stable IDs across renders: useId
Section titled “Stable IDs across renders: useId”The second fix is narrower, and it exists for exactly 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 tempting ways to generate that ID are all non-deterministic. Math.random() gives a different number on each machine. A module-level counter increments in a different order on the server than in the browser. crypto.randomUUID() is random by definition. Every one of them produces a different ID on the server than in the browser, and a different id attribute in the HTML is a mismatch.
useId is React’s purpose-built answer. It derives a stable, unique string from the component’s position in the render tree rather than from any random source, so the server and the browser, walking the same tree, 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 in more depth later in the course, when you cover forms and accessibility; here it earns its place purely as the hydration-safe source of IDs. The rule is short: never reach for a random number or a counter to make an ID inside a Client Component subtree. That habit is the exact trap useId was built to remove.
When you can’t defer: suppressHydrationWarning, narrowly
Section titled “When you can’t defer: suppressHydrationWarning, narrowly”The last fix is an escape hatch, and it comes last on purpose because it is easy to misuse. suppressHydrationWarning is a boolean prop you put on a single element. It tells React to skip the mismatch check for that element’s own text content and attributes only, not its children and not the rest of the tree. One element, one level deep.
There are exactly two times you should reach for it.
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 one <time>, let the post-hydration render correct it, and the user never sees a flash.
The second is the browser’s noise from the second bucket, those extension-injected attributes. Since extensions mutate the document <body>, that’s where the suppression goes, so a stray data-gr-* or cz-shortcut-listen doesn’t trip the warning for your whole app.
There’s a subtle point here, and you’ve already seen one half of it. Back 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 browser-extension case you just learned puts the same prop on <body> instead. Same prop, two different elements, two unrelated reasons. So when you see <html suppressHydrationWarning> in a root layout, read it as the theme script, not the 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> );}Here is the limit that keeps this prop honest. suppressHydrationWarning silences only that element, one level deep, and every child below it still hydrates strictly. So it is not a way to quiet a mismatch you don’t understand. If you add it to make a bug disappear that you could have fixed with useEffect, you are hiding a real divergence, and it will resurface somewhere worse. The discipline is to reach for useEffect or useId first. Use suppressHydrationWarning only for values that are correctly different by design, plus the body-level extension noise, and nothing else.
You now have all three fixes. Match each scenario below 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 diagnostics before you debug
Section titled “Two diagnostics before you debug”Two checks save the most time, and both are things to confirm before you start changing render code.
Is it even a hydration bug? Only Client Components hydrate. A Server Component ships HTML plus reconciliation data and never runs a second time in the browser, so it cannot throw a hydration mismatch. That gives you a one-glance diagnostic: open the file the error points at and look for "use client" at the top, either directly or because it’s imported from a file that has it. If there’s no client boundary anywhere 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 exactly the picture from the trace at the top: the interactive leaf hydrates and the rest of the tree doesn’t, so the rest of the tree can’t be the source of a hydration mismatch.
The stale .next cache. Sometimes the error points at HTML you genuinely cannot find anywhere in your source. Before you doubt your own eyes, consider the dev build cache (.next): it 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 rather than a concept, but naming it spares you an hour of chasing a bug that was never in your code.
To step back: hydration is the seam where the server and client halves of the App Router meet, and its failure modes are a small, recognizable set. Every one of them resolves to one of two moves. Either you make the first render deterministic, which is your bucket, fixed with useEffect or useId, or you acknowledge an environment difference you don’t control, which is the browser’s bucket, handled with a narrow suppressHydrationWarning. Read the error, classify the cause, pick the fix. That seam is the price the App Router pays for instant HTML and interactivity, and these moves are how you keep it from breaking.
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.