Piotr Czerwiński

Writing · June 16, 2026 · 10 min read

Hydration mismatches in the wild: two production specimens and a diagnostic method

React · SSR · hydration · debugging

TL;DR: Two production bugs from my side projects, one shared disease. In the first, toLocaleString() without an explicit locale rendered 70,000 on the server and 70 000 in Polish browsers, so React threw hydration error #418, but only for some users. In the second, a dark theme set by an inline script in <head> kept flashing back to light, but only on physical iPhones, because iOS WebKit strips attributes from <html> shortly after hydration. Different symptoms, same class of bug: the server HTML and the first client render disagreed. The general method: find the environmental input that differs between the two runtimes, then either make the output deterministic or re-assert it before paint.

Hydration mismatches are a class, not a bug

Server-side rendering makes a promise: the HTML the server sends is exactly what the client's first render would have produced. Hydration is React checking that promise. When it breaks, React discards the mismatched subtree and re-renders it on the client, which you experience as a flash of changing content, layout shift, and a stream of errors in your tracking tool.

What makes this class nasty is that the mismatch usually depends on something environmental: locale, timezone, current time, device, browser engine. So it reproduces for some users and not for you; dev, CI, and staging stay green while a slice of production watches your page flicker. Here are two specimens I debugged in production, and the method they share.

Specimen 1: the number that changed nationality

The symptom was a steady stream of Minified React error #418 ("hydration failed, server-rendered text didn't match") on a listing page. Not for everyone: the errors clustered on Polish browser locales, mostly Chrome on Android. On my machine the page was flawless.

The root cause was one innocent-looking line in a shared formatting helper: value.toLocaleString() with no arguments. Without an explicit locale, toLocaleString() (and Intl.NumberFormat and Intl.DateTimeFormat) use the default locale of whatever runtime executes them:

  • On the server, Node on a typical hosting platform defaults to en-US, so (70000).toLocaleString() produces 70,000 with a comma.
  • In the browser, the same call uses the user's locale: pl-PL gives 70 000 with a space, de-DE gives 70.000 with a dot.

The server bakes one string into the HTML, the client's first render computes another, and React throws the subtree away and re-renders it. Every formatted number flashed on load for every user whose locale disagreed with the server. American visitors never saw it. Polish visitors saw it on every page view.

The same mechanism covers dates: toLocaleDateString() without an explicit timeZone renders in the server's zone (usually UTC) versus the user's, so a timestamp near midnight can land on different calendar days. And Date.now() in a render path mismatches by construction on any cached page: the server froze "now" at build time, the client hydrates at actual now. "3 days ago" labels and freshness badges are the classic victims.

The options on the table

Once the cause was clear, there were five ways out, and they are not equivalent.

Slap suppressHydrationWarning on the element. The cheapest diff, but it only silences the console: React still re-renders on the client, so the flash stays, and the flag blinds you to future genuine mismatches there. Hiding the bug, not fixing it. Rejected.

Format on the client only. Render a placeholder on the server and format after mount. The match is guaranteed because the server never commits to a format, but the number pops in after hydration, its own flash plus CLS, and these numbers were SEO-relevant content I wanted in the server HTML. Rejected here, though the mounted gate stays in the toolbox for values that cannot be known on the server.

Pin an explicit locale. n.toLocaleString('pl-PL') is a tiny diff and keeps native formatting. The trap: server and browser can ship different ICU versions, and ICU has changed which space character some locales use as the group separator (regular space, no-break space, narrow no-break space). Two strings that look identical on screen can differ by one invisible code point, and React compares code points. Rare, but miserable to diagnose.

Format with a deterministic helper. A few lines that produce the identical string in every runtime, with no ICU involved at all:

const groupThousands = (n: number) =>
  String(Math.trunc(n)).replace(/\B(?=(\d{3})+(?!\d))/g, '\u00A0');

The con is narrowness: this is one format, hardcoded, not an i18n system. For my case the display format was fixed anyway.

Format on the server and pass the string down. If a server component computes the final display string and the client component only renders it, nothing is left to diverge. The cleanest conceptually, but it changes component props across every call site.

I chose the deterministic helper for numbers: the fix lands in one shared formatting function, no component APIs change, and the output stops depending on which ICU either runtime ships. Dates got explicit locale plus pinned timeZone, because hand-rolling date formatting is where new bugs breed. Anything derived from Date.now() gets computed on the server and passed down as a prop.

Tracing it when the stack trace is useless

A minified #418 gives you a chunk name and little else. What identified the bug was segmentation, not stack traces: group the errors by URL to find which page, then look at the victims' browser and locale to find which axis. When every affected user shares a locale, a grep for toLocaleString, Intl., Date.now() and new Date( across client components finds the culprit in minutes. Verification is mechanical: load the page in a browser whose locale differs from the server's, confirm the deterministic format renders instead of the native one, and confirm the console is clean of #418.

Specimen 2: the attribute that vanished only on iPhones

The second specimen starts from a textbook-correct setup: an inline script in <head> reads the stored preference and sets a dark class on <html> before first paint, the standard anti-flash pattern. A consent banner used the same trick with a data attribute, and suppressHydrationWarning sat on <html>. The bug report: on iPhones, and only on iPhones, the dark theme flashed back to light after load, and the consent banner blinked for users who had already dismissed it. Desktop Safari on a Mac was fine. Every other browser was fine.

Then came the humbling part: I could not reproduce it anywhere. Chromium: clean. Playwright's WebKit build with iPhone device emulation: clean. Production over a real network: clean. A per-frame requestAnimationFrame sampler hunting for a single wrong-themed frame: zero hits. A MutationObserver on the attributes: silence. The bug existed only on physical iOS devices.

The mechanism, as far as I could establish it: physical iOS WebKit removes attributes from <html> shortly after hydration. The inline script runs, paint happens correctly, and then the attribute is gone. suppressHydrationWarning does nothing here; it silences React's warning, it does not protect the DOM. I never pinned down the exact trigger, and I stopped trying once I realized the fix does not depend on knowing it.

The options on the table

With no reproducible environment, every candidate fix had to be judged on reasoning first and confirmed on a physical phone second. Four came up.

Restore the class in useEffect. My first attempt, and it is what turned the revert into a flicker: useEffect runs after paint, so the user sees the wrong frame before the correction lands. It fixes the end state and fails the experience. Rejected.

Watch with a MutationObserver and restore on removal. Appealingly surgical, but an observer also fires after the mutation is committed, so a wrong frame can still paint before the restore. Worse, my observer never fired in any environment I could instrument, meaning I would ship reactive code testable only on the one device that had the bug. Rejected.

Move the theme off the <html> attribute entirely. A pure CSS prefers-color-scheme approach has no attribute to clobber, but removes the manual theme override users already had. Hanging the class on <body> might dodge the quirk, but with no repro in tooling I could not verify it dodges anything, and rewriting every selector on a guess is a large bet on an unproven theory. Rejected.

Restore in useLayoutEffect. It runs after React commits but before the browser paints, so the restoration is invisible regardless of what removed the attribute or why:

const useIsomorphicLayoutEffect =
  typeof window !== 'undefined' ? useLayoutEffect : useEffect;

function ThemeSync({ dark }: { dark: boolean }) {
  useIsomorphicLayoutEffect(() => {
    document.documentElement.classList.toggle('dark', dark);
  }, [dark]);
  return null;
}

This is the option I shipped, for one decisive reason: it is the only candidate whose correctness does not depend on understanding the trigger. Whatever strips the attribute, whenever it does so, the pre-paint restore wins. It was also the smallest change and verifiable with reasoning plus a single check on a real phone. The consent banner got the same treatment: the state flip that hides it for returning users moved into useLayoutEffect, so it disappears before paint instead of blinking. I kept the earlier layers too. What shipped is three layers covering different windows: the inline script sets the attribute before first paint, suppressHydrationWarning keeps React from complaining, and the useLayoutEffect re-applier restores the attribute if anything strips it later. iOS needs the third layer; the first two are not enough on their own.

One adjacent gotcha made the bug look stranger: only the home page flickered. The re-applier lived in a route group layout, and in the Next.js App Router a root page outside the group does not pass through that group's layout, so home never mounted it. Cross-cutting concerns like theme sync belong in the root layout, where every route inherits them.

The diagnostic method

Two very different bugs, one procedure. When something flashes, shifts, or throws #418, I walk the same five steps. Name the two renders that must match: server HTML and first client render are a contract, and the job is finding where it breaks. Hunt for the environmental input: any render path that reads locale, time, timezone, storage, or anything window-shaped is a suspect, and the grep list is short. Let the victims tell you the axis: a bug that only hits Polish Android users is shouting "locale"; one that only hits physical iPhones is shouting "engine quirk", and also telling you where verification must happen. Reproduce by forcing the difference, and know when to stop: switching my browser locale reproduced the first bug instantly, while no tooling reproduced the second, and the right move there was to reason from symptom to mechanism and verify on a real device. Finally, fix by determinism or by re-assertion: if you control the output, make it identical in both runtimes; if something outside your control mutates the DOM afterwards, restore it before paint.

Lessons

  • Never call toLocaleString or Intl APIs without an explicit locale in code that renders on both server and client. Better yet, use a deterministic helper; even explicit locales can diverge across ICU versions by an invisible space character.
  • Anything derived from "now" is a hydration hazard on cached pages. Compute it on the server, or defer it to after mount, deliberately.
  • suppressHydrationWarning suppresses a warning, nothing else. It will not stop the DOM from being changed under you.
  • useEffect fixes state, useLayoutEffect fixes state invisibly. If the correction must not be seen, it has to run before paint.
  • Segment errors by victim, not by stack trace. A minified hydration error tells you almost nothing; the URL, locale, and device of the affected users tell you almost everything.
  • Some bugs only exist on real hardware. When emulation and instrumentation come back clean but users keep reporting it, believe the users and test on the physical device.
  • Put cross-cutting client concerns in the root layout. A route group layout silently skips any page outside the group.