Skip to content

CONCEPT Cited by 2 sources

Hydration mismatch

A hydration mismatch is the failure mode where a React (or similar SSR-then-CSR framework) app produces different markup on the server than on the client for the same component, at the same place in the tree, on the initial render. At hydration time the client's React instance walks the server-rendered DOM and compares what it would render to what's already there; on the first divergence it can't safely attach event handlers because the fiber and the DOM node disagree on identity.

React 18's hydrateRoot API is substantially stricter than React 17's hydrate: where 17 would silently drop to client-side-rendering and repaint (user-visible but rarely alarmed), 18 calls onRecoverableError on every mismatch and can cascade — one mismatch knocks the fiber-list-vs-DOM-list comparison out of alignment, and subsequent nodes also report errors that are false-positive consequences of the first.

Why it matters

A hydration mismatch has three user-visible consequences:

  1. The mismatched subtree re-renders client-side — TTVC regresses to approximately client-rendering time for that subtree.
  2. Interactivity is delayed on the affected subtree because event handlers don't attach until the re-render commits.
  3. Visual flicker / layout shift if the server-rendered and client-rendered markup differ in size or position.

At scale, a codebase with dozens of mismatch root causes across hundreds of components produces a Sentry-log tidal wave that's hard to act on without dedup (see patterns/first-error-only-hydration-error-reporting).

Taxonomy (Zalando 2023)

The Zalando Rendering Engine post (Source: sources/2023-07-10-zalando-rendering-engine-tales-road-to-concurrent-react) gives the wiki's most detailed production taxonomy, surfaced from "hundreds of Renderers" on the Fashion Store codebase after the React-18 migration. Four categories:

1. Timers / time-deltas

Server computes a time-delta (targetDate.getTime() - Date.now()) at SSR time t₀; client re-computes at t₀ + δ. Values differ by δ — guaranteed mismatch. No application bug — the mismatch is semantically expected.

Fix: suppressHydrationWarning={true} on the closest wrapping element. See patterns/suppress-hydration-warning-for-unavoidable-mismatch. Caveat: only one level deep; wrap precisely.

2. Timezone-localised dates

Intl.DateTimeFormat(locale) or date.toLocaleString(locale) without an explicit timeZone parameter defaults to the host's timezone. The SSR server's host timezone (e.g. UTC in a containerised deployment) and the user's browser host timezone (whatever Central Europe is currently on) differ, and so do the rendered dates.

Fix (application-side): always pass an explicit timeZone:

date.toLocaleString(locale, { timeZone: universalTimezone })
consistently on both sides.

Fix (architecture-side): move date localization to the backend; the component renders a pre-localised string. See patterns/backend-localization-for-hydration-stability.

See concepts/locale-host-default-ssr-csr-divergence for the general form.

3. Number localization (and a Safari de-AT bug)

Similar to dates: (12345).toLocaleString() without a locale uses the host's. Pass an explicit locale to fix most cases.

But one specific case can't be fixed in application code:

"Safari browser where for the de-AT locale, the localisation APIs (like Intl.NumberFormat or tolocalestring) generate values like "2.345" but other browsers including Chrome and Firefox as well as Node.js generate values like "2 345" for the same locale!" (Source: sources/2023-07-10-zalando-rendering-engine-tales-road-to-concurrent-react.)

This is a pure runtime-divergence between the Node.js SSR server and Safari, with neither fixable application-side. The only fix is moving localization to the backend so the rendered string bypasses both Intl instances.

4. Invalid HTML nesting

React 18 treats structurally invalid HTML as a hydration mismatch. Examples:

  • <div> inside <p> (browser parser auto-closes the <p> before the <div>, so the DOM disagrees with the fiber tree).
  • <button> inside <button> (interactive-content nesting restriction).

The root cause: the HTML parser corrects structurally-invalid markup at parse time, producing a DOM that doesn't match what React's renderer thinks it produced.

Fix: use semantically valid HTML nesting. The post recommends eslint-plugin-validate-jsx-nesting for enforcement.

Detection signals (Sentry)

Hydration-mismatch logs carry these signals:

  • componentStack from hydrateRoot's onRecoverableError(error, errorInfo) — the React fiber stack at time-of-error. In dev builds, readable; in prod builds, minified — source-map unminification needed.
  • validateDOMNesting(...) substring — indicates invalid-HTML-nesting root cause (category 4).
  • Multiple errors per single mismatch — because post-first- mismatch the fiber-vs-DOM list alignment breaks and subsequent nodes also fail. The cause fiber is usually not the first logged fiber. See patterns/first-error-only-hydration-error-reporting.

Debugging recipe (Zalando 2023)

"In other cases where the cause is not very obvious, what we found helpful was to check the React dev bundle (react-dom/umd/react-dom.development.js) and put debuggers on places which log the hydration errors (usually the checkForUnmatchedText or throwOnHydrationMismatch functions). Then by loading the page, try to find out what is the exact React fiber that causes the issue, and based on that find the component/element. Don't be afraid to go higher in the stack and use more debuggers! In some cases we realized that the fiber is the same element that caused the issue, but in others, it's more confusing as the fiber is something that was rendered after a mismatching (usually missing) node instance that was the actual cause of the issue." (Source: sources/2023-07-10-zalando-rendering-engine-tales-road-to-concurrent-react.)

Key dev-bundle functions to breakpoint:

  • checkForUnmatchedText — where text-content mismatches are detected.
  • throwOnHydrationMismatch — the throw path, with the failing fiber in scope.

Variables worth inspecting at the breakpoint: fiber, nextInstance, current, and their received props.

Interaction with streaming SSR

Under concepts/streaming-ssr, hydration runs per Suspense boundary. A mismatch is contained to the boundary it occurs in; other boundaries hydrate successfully. But: the state that drove server rendering of a boundary must be injected before the boundary's markup or every boundary of a type hits the same mismatch. Confluence (sources/2026-04-16-atlassian-streaming-ssr-confluence) uses a NodeJS objectMode transform that flushes state before markup on setImmediate.

Interaction with mount-gated rendering

For content that genuinely should differ SSR vs CSR (device- specific banners, browser-only state), the escape hatch is mount-gated rendering: render a fallback on SSR and the real content after client mount. This side-steps the mismatch at the cost of a post-hydration render of that subtree.

Seen in

  • sources/2023-07-10-zalando-rendering-engine-tales-road-to-concurrent-reactcanonical wiki instance. Four-category taxonomy with concrete code pairs, the Safari de-AT Intl.NumberFormat bug, the "first-error-only" Sentry dedup pattern, and the dev-bundle debugger recipe. Surfaced from a production e-commerce React codebase with "hundreds of Renderers" post-React-18 upgrade.
  • sources/2026-04-16-atlassian-streaming-ssr-confluence — Atlassian Confluence's React 18 streaming SSR pipeline; the state-injection-ordering discipline (state before markup per boundary) is the streaming-SSR-specific prevention, and the React 18 useContext-across-ready-boundary bug is a subset of mismatch later fixed in React 19.
Last updated · 501 distilled / 1,218 read