Hydration Mismatches: Debugging React's Most Passive-Aggressive Error
"Hydration failed because the server rendered HTML didn't match the client." If you've shipped anything server-rendered with React, you've met this error. It's passive-aggressive in a very specific way: it tells you that the server and client disagreed, but not where, not why, and — in production builds — not even what the difference was.
I want to walk through the worst hydration bug I've ever chased, because the debugging process generalizes to almost every hydration mismatch you'll ever see.
What hydration actually promises
During SSR, React renders your component tree to an HTML string on the server. On the client, hydrateRoot walks the same component tree and attaches event listeners to the existing DOM, assuming the markup matches what the components would render. That assumption is the entire contract:
When the contract breaks, React 18+ doesn't patch the difference quietly. It throws away the server-rendered DOM for that root and re-renders entirely on the client — you pay for SSR and then pay again, with a visible flash as the bonus.
The four usual suspects
Nearly every hydration mismatch I've debugged in fourteen years of doing this reduces to one of four causes.
1. Time is not a pure function
Our Sydney bug was this one. The header component did:
function PnlHeader({ asOf }: { asOf: string }) {
// ❌ Renders differently on server (UTC) and client (trader's machine)
const label = new Date(asOf).toLocaleDateString("en-AU", {
weekday: "long",
day: "numeric",
month: "short",
});
return <h2>P&L — {label}</h2>;
}
The server ran in UTC. A trader loading the page at 8am Monday in Sydney was still Sunday evening in UTC — so the server said "Sunday", the client said "Monday", React discarded the tree, and the re-render triggered the table's default sort. Two symptoms, one root cause.
The fix that actually works is to make the server's output deterministic and defer local formatting until after mount:
function PnlHeader({ asOf }: { asOf: string }) {
const [label, setLabel] = useState<string | null>(null);
useEffect(() => {
// Runs only on the client, after hydration succeeds
setLabel(formatInTraderTimezone(asOf));
}, [asOf]);
// Server and first client render agree: show the stable UTC form
return <h2>P&L — {label ?? formatUTC(asOf)}</h2>;
}
2. Randomness: Math.random(), uuid(), and friends
Generating IDs during render means the server generates one set and the client another. React's useId exists precisely for this — it derives IDs from tree position, so both sides agree:
// ❌ const id = `field-${Math.random().toString(36).slice(2)}`;
const id = useId(); // ✅ stable across server and client
3. Invalid HTML nesting
The browser's parser silently fixes invalid HTML before React ever sees it. If your JSX renders <p><div>...</div></p>, the server sends that markup, the browser hoists the div out of the p, and hydration then compares React's tree against the browser's corrected version. They can't match. Tables are the classic trap — a <tr> rendered without a <tbody> gets one inserted automatically. React 19's error messages got much better at naming the exact element, which has saved me hours.
4. Things that aren't your code at all
Browser extensions inject DOM before hydration runs — Grammarly, password managers, ad blockers, translators. On the trading dashboard we also had a compliance-mandated session recorder that mutated <body> attributes. If mismatches only affect some users and your code audits clean, ask what's running in their browser, not yours.
A debugging workflow that actually converges
When the error hits and the component isn't obvious, this is the sequence I teach every team:
- Reproduce in development. Dev builds show the actual DOM diff; production builds only tell you a mismatch occurred.
- Binary-search with
suppressHydrationWarning— temporarily, on subtrees — to bisect which branch disagrees. Remove it once found. - Diff the real HTML.
curlthe page, save it, then compare againstdocument.documentElement.outerHTMLafter load. The mismatch is in that diff, always. - Interrogate every render-time input:
Date,Math.random, locale APIs,typeof windowbranches, feature flags evaluated at different times on server and client. Aflag ? <A/> : <B/>where the flag snapshot differs between the server render and client bootstrap is a hydration bug wearing a trench coat.
# Server truth
curl -s https://dashboard.internal/positions > server.html
# Client truth (after load, before interacting)
# copy(document.documentElement.outerHTML) in DevTools → client.html
diff <(npx prettier --parser html server.html) \
<(npx prettier --parser html client.html)
The Sydney bug took us six days to find and four lines to fix. That ratio is typical for hydration issues, which is exactly why having a workflow matters more than having the fix memorized.