Postmortem: the React hydration error that survived three deploys
Why hydration errors are uniquely hard to spot, and the diff between an error tracker that buries them and one that surfaces them.
It's 2:00 PM on a Tuesday. CI is green, unit tests pass, the site looks fine on your machine. And in the background, your conversion rate is quietly down 4%. Deep in the browser console of roughly 15% of your users, one line repeats over and over: Error: Hydration failed because the initial UI does not match what was rendered from the server. Because this is not a hard crash—the page still renders, more or less—your per-event error tracker has already throttled the logs to spare your quota. You are blind to a bug that has now survived three consecutive "fix" deploys.
Hydration errors are the most under-appreciated category of modern web bug. They rarely take the page down outright, so they get filed as low-severity console noise. But they tank Core Web Vitals, they flicker the UI at exactly the wrong moment, and they are frequent—which in a per-event pricing model makes them the first thing teams filter out to save money. This is the story of one hydration error that hid in plain sight for three deploys, and how session replay finally made it visible.
The Ghost in the DOM: What Is a Hydration Mismatch?
Server-side rendering frameworks like Next.js do their work in two phases. The server renders your component tree to HTML and ships it so the user sees content immediately. Then, in the browser, React "hydrates" that static HTML—walking the existing DOM and attaching event handlers, expecting the DOM it finds to match exactly what it would have produced itself.
The handshake between server and client
Hydration is a handshake built on a strict assumption: the HTML the server sent and the HTML the client would generate on first render are byte-for-byte identical. When that assumption holds, hydration is invisible and fast. When it breaks—even by one text node—React has a problem, because it trusted the server's markup and now finds it doesn't match reality.
Why React gives up
In React 18, when the client render disagrees with the server HTML, React cannot safely patch the difference. It throws out the server-rendered DOM for the affected subtree and re-renders it from scratch on the client. You will see this surface as error #418 (text content mismatch) or #423 in the production, minified error codes. The classic trigger is any value that differs between server and client render time:
function Countdown({ deadline }) {
// Runs on the server in UTC, runs on the client in the
// user's local timezone -- the two strings will not match,
// and React throws a hydration error on first paint.
const remaining = formatDistance(new Date(), deadline);
return <span>Time remaining: {remaining}</span>;
}
// Math.random() and Date.now() are the other usual suspects.The Timeline of a "Minor" Bug
What makes hydration errors so insidious is how easy they are to misdiagnose. Ours survived three releases, each "fix" treating a different symptom.
Deploy 1: the countdown that worked locally
We shipped a "Time Remaining" countdown on the checkout page—the exact pattern above. On the developer's machine, local timezone and server timezone were the same, so the strings matched and hydration succeeded. In production, the server rendered in UTC and the user's browser rendered in their local zone. Mismatch on first paint, for every user not on UTC. It shipped clean and broke silently.
Deploy 2: the "CSS fix"
The visible symptom was a flicker—the countdown text would appear, then jump as React re-rendered the subtree. A developer reasonably read that as a layout shift and added min-height to stop the jump. It made the flicker slightly less jarring and did nothing about the actual mismatch. The hydration error kept firing; we just made it look a little calmer.
Deploy 3: the suppression
Frustrated, someone reached for suppressHydrationWarning—and put it on the wrong element, a parent wrapper rather than the specific text node. This is the most dangerous "fix" of the three, because it silences the warning without correcting the mismatch. The console went quiet. The error—and the conversion drag—continued. We had successfully hidden the evidence from ourselves.
Why Traditional Error Trackers Buried the Lead
Through all three deploys, our error tracker was technically receiving these events. It just never made us look at them.
The warning-vs-error problem
Many SDKs and triage workflows treat hydration issues as low-priority console noise, lumped in with deprecation warnings and third-party chatter. A hydration mismatch doesn't crash the app, so it sorts to the bottom of the list and nobody opens it.
The cost of visibility
This is the part nobody likes to admit. Under per-event pricing, a high-frequency, "low-severity" error like a hydration mismatch firing on 15% of sessions is a budget threat. The rational response—the one teams actually take—is to add an inbound filter that drops "Hydration" events so they don't burn the monthly quota. You are now paying a tool to not tell you about your most frequent bug. We dig into that perverse incentive in our writeup on moving off per-event tracking.
Lack of context
Even when you do open a hydration event, the stack trace is nearly useless. It points into react-dom.production.min.js—React's internals—not your source. Without good source maps it tells you React noticed a mismatch, but not which of your components caused it or what the two mismatched values were. You can paste the minified codes into our free hydration error decoder to translate #418/#423 into the readable message, but that still only names the category, not your specific culprit.
Seeing the Mismatch with Session Replay
What finally cracked it was watching the bug happen instead of reading about it.
The visual diff
In a session replay of an affected user, the failure is unmistakable: the countdown renders the server's value for a beat, then visibly flickers and resets as React re-renders the subtree on the client. You can scrub to the exact millisecond it happens. That flicker, which is invisible in a stack trace and easy to dismiss in person, is glaring on a timeline.
DOM inspection before and after
Because the replay serializes the DOM, we could inspect the markup immediately before and immediately after the hydration attempt and read the two values React was comparing. Side by side with the console error, the mismatched text node was right there—the UTC string the server sent versus the local-time string the client produced. No guessing, no reproduction. The replay handed us both halves of the mismatch.
Root Cause: The Browser Extension Variable
There was a second twist that the replay also exposed, and that we would never have found in aggregate logs. After fixing the timezone bug, a residual stream of hydration errors persisted on a smaller cohort. The replays for those sessions showed extra DOM nodes that our code never rendered—markup injected by common browser extensions (password managers, translation tools, coupon finders) that modify the page before React hydrates it.
Finding the 2% pattern
Extension-induced hydration errors are real, mostly unfixable from your side, and easy to confuse with your own bugs. The only way to tell them apart is to see the injected nodes and correlate the pattern across many sessions. That required capturing a lot of "noisy" events—over ten thousand—to isolate the 2% that were genuinely caused by extensions versus the ones still in our control. Under per-event pricing we would have been financially punished for collecting exactly the data needed to diagnose the problem. Flat-rate capture is what made the pattern findable at all. (For the broader version of this lesson—that most errors are noise and the skill is separating signal from it—see our notes on triaging noisy errors at scale.)
Strategies for Prevention and Faster Resolution
Hydration errors are far easier to prevent than to chase. A few durable habits:
- Defer client-only values to
useEffect. Anything that depends on the browser—current time in local zone,window,localStorage, random IDs—should render a stable placeholder on the server and update after mount, so the first client render matches the server. - Standardize the rendering timezone. Render time-sensitive values in a fixed zone (UTC) on both sides, or compute relative time exclusively on the client after hydration.
- Use
suppressHydrationWarningsurgically. Only on the precise node whose mismatch is genuinely expected and harmless (like a timestamp), never on a wrapper. - Alert specifically on hydration errors. Don't bury them in a generic bucket—give
#418/#423their own alert so a regression surfaces immediately instead of surviving three deploys.
// A client-only wrapper that sidesteps mismatches entirely.
function ClientOnly({ children }) {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
return mounted ? children : null; // server renders nothing; client fills in
}One more reason to take these seriously: when React discards a server-rendered subtree and re-renders it on the client, that work runs on the main thread and inflates Total Blocking Time, which degrades INP and your Core Web Vitals. A "harmless" hydration flicker is also a measurable performance tax—see how INP, LCP, and CLS map to conversion for what that costs.
Don't Pay to Be Blind
The deepest lesson here is not about React. It is that "silent" errors are often more expensive than crashes—a crash gets fixed in an hour because everyone can see it, while a hydration error drags conversion for weeks because your tooling was tuned, by your own budget pressure, to look away. Error tracking should be about visibility, not budget management.
That is why GlitchReplay captures every event and every session at a flat rate behind a Sentry-compatible SDK. You never have to filter out your most frequent bug to protect your bill, and when a hydration error fires you can jump straight from the error to a replay and watch the UI flicker for yourself. Stop guessing why your React UI is resetting. See exactly what your users see.
GlitchReplay is Sentry-SDK compatible, includes session replay and security signals, and never charges per event. Free to start, five minutes to first event.