Skip to content

React Hydration Mismatch: Causes and How to Fix It

Fix 'Hydration failed because the server rendered HTML didn't match the client' — every documented cause, React 19.3's new browser() API, and the real fix.

React react-errors react-dom react-19
Bharath G
Reading Progress

On This Page

If you SSR anything in React, you've seen this one. It's the single most common production error report from React apps that render on the server, and as of React 19.3 (shipped September 9, 2026) there's finally a first-class API — browser() — built specifically to stop causing it. Here's the error, why it happens at the fiber level, and how to actually fix it instead of papering over it.

The Error

On React 19 (19.0 through 19.3), a hydration mismatch throws one consolidated message with a diff:

Uncaught Error: Hydration failed because the server rendered HTML didn't match the client. As a result this tree will be regenerated on the client. This can happen if a SSR-ed Client Component used:

- A server/client branch `if (typeof window !== 'undefined')`.
- Variable input such as `Date.now()` or `Math.random()` which changes each time it's called.
- Date formatting in a user's locale which doesn't match the server.
- External changing data without sending a snapshot of it along with the HTML.
- Invalid HTML tag nesting.

It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.

<App>
  <span>
+ Client
- Server

    at throwOnHydrationMismatch (react-dom-client.development.js)
    at completeWork (react-dom-client.development.js)
    at runWithFiberInDEV (react-dom-client.development.js)
    at completeUnitOfWork (react-dom-client.development.js)

The +/- diff under the component stack is new in 19 — it tells you exactly which node and text disagreed, instead of making you diff two HTML dumps by eye.

The production build ships the minified form instead, with two distinct codes depending on where in the process it happens:

Uncaught Error: Minified React error #418; visit https://react.dev/errors/418?args[]=div for the full message
Uncaught Error: Minified React error #423; visit https://react.dev/errors/423 for the full message

#418 is the mismatch itself ("Hydration failed because the server rendered %s didn't match the client…"). #423 is the recovery notice: "There was an error while hydrating but React was able to recover by instead client rendering the entire root." You'll often see both in the same session — 418 when the diff is detected, 423 once React gives up patching and remounts the affected subtree from scratch on the client.

Before React 19, the same failure produced multiple separate, less useful warnings on React 18:

text

Warning: Text content did not match. Server: "Server" Client: "Client" at span
  at App
Warning: An error occurred during hydration. The server HTML was replaced with client content in <div>.
Uncaught Error: Text content does not match server-rendered HTML.

Same root cause, three log lines, no diff. React 19 collapsed this into the single message above — this is a logging/DX change only, not a behavior change to when mismatches occur.

How to Reproduce It

The fastest way to see it with nothing else in the way is a minimal Express + React SSR server — no framework opinions to fight.

text

mkdir hydration-repro && cd hydration-repro
npm init -y
npm install react@19.3.0 react-dom@19.3.0 express@4.21.2
npm install -D tsx@4.19.2 typescript@5.7.3 @types/react@19.3.0 @types/express@4.17.21

package.json dependency block:

json

{
  "dependencies": {
    "express": "4.21.2",
    "react": "19.3.0",
    "react-dom": "19.3.0"
  },
  "devDependencies": {
    "@types/express": "4.17.21",
    "@types/react": "19.3.0",
    "tsx": "4.19.2",
    "typescript": "5.7.3"
  }
}

App.tsx — the bug is the typeof window branch:

tsx

export function App() {
  // Renders "Server" during SSR, "Client" once this file re-runs in the browser.
  const where = typeof window !== 'undefined' ? 'Client' : 'Server';
  return (
    <div id="root-content">
      <span>{where}</span>
    </div>
  );
}

server.tsx:

tsx

import express from 'express';
import { renderToString } from 'react-dom/server';
import { App } from './App';

const app = express();
app.use('/client.js', express.static('dist/client.js'));

app.get('/', (_req, res) => {
  const html = renderToString(<App />);
  res.send(`<!doctype html>
<html><body>
<div id="root">${html}</div>
<script type="module" src="/client.js"></script>
</body></html>`);
});

app.listen(3000, () => console.log('http://localhost:3000'));

client.tsx:

tsx

import { hydrateRoot } from 'react-dom/client';
import { App } from './App';

hydrateRoot(document.getElementById('root')!, <App />);

Build the client bundle and run the server:

bash

npx esbuild client.tsx --bundle --outfile=dist/client.js --format=esm
npx tsx server.tsx

Open http://localhost:3000 — the page flashes "Server" then "Client", and the console throws the error from Section 1. It's environment-specific in a useful way: it only fires on the first client render after hydration, only in a real browser context (Node-side renderToString never sees the mismatch, since typeof window is undefined there by definition), and it disappears if you skip hydrateRoot and use createRoot instead — because then there's no server HTML to disagree with.

The same bug in Next.js App Router looks identical in app code — app/page.tsx with {typeof window !== 'undefined' ? 'Client' : 'Server'} and no "use client" boundary around the browser check — but next build && next start additionally minifies the message to #418/#423, where next dev keeps the full text.

Version Behavior Matrix

VersionMessageRecoveryNotes
React 17Separate warnings per mismatched node; no automatic recovery modelManual — app often left with broken interactivityLegacy ReactDOM.hydrate, no concurrent root
React 18.0–18.2Multiple console warnings + generic thrown errorClient re-render of the affected root, introduced with hydrateRootcreateRoot/hydrateRoot shipped in 18.0 (March 2022)
React 18.3Same as 18.2SameDeprecation warnings added ahead of the React 19 cutover
React 19.0Single consolidated message with +/- diff (this article's Section 1)Recoverable via onRecoverableError root optionReleased Dec 5, 2024. useId prefix: :r:
React 19.1Same message format, adds owner stacks in devSameReleased Mar 28, 2025. useId prefix: «r»
React 19.2Same, plus Suspense boundaries now batch their reveal on the server to match client timing (fewer boundary-order mismatches)SameReleased Oct 1, 2025. useId prefix: _r_ (valid in CSS selectors and view-transition-name)
React 19.3Same message, but the browser() API (new) lets you opt a subtree out of SSR entirely instead of producing a mismatchSame, plus Strict Mode now double-invokes Effects during hydration too, to match client-rendered rootsReleased Sep 9, 2026

Framework layer:

Next.jsBehavior
14 (Pages + early App Router)Hydration errors surface as the React 18-era multi-warning form; next dev overlay shows the offending component
15React 19 adopted; params/searchParams becoming Promises introduces a new mismatch source if you read them synchronously in a Client Component
16Same React 19.x message format; browser() is usable directly once you're on react@19.3/react-dom@19.3, which Next 16.3.x supports

No React 20 has been announced as of this writing (React 19.3 is current, September 2026). View Transitions and Fragment Refs, previously Experimental, are now stable as of 19.3 and are unrelated to hydration mismatches directly, but they do interact with the Suspense-reveal batching mentioned above.

Why It Happens — Surface Level

React's SSR path (renderToString / renderToPipeableStream) runs your component tree once on the server to produce HTML, then runs it again in the browser during hydrateRoot to build the fiber tree and attach event handlers to the existing DOM nodes rather than replacing them. If anything the component reads gives a different answer the second time — typeof window, Date.now(), a browser-only API, locale-dependent formatting, or data that changed between the server response and the client mount — the second render produces different text or elements than what's already sitting in the DOM, and React's hydration diff catches the discrepancy.

Why It Happens — Under the Hood

During hydration, React doesn't build a fresh DOM tree — it walks the fiber tree it's constructing for the client render in lockstep with the existing DOM nodes left by the server (document.getElementById('root')'s children), claiming each one via tryToClaimNextHydratableInstance/getNextHydratableSibling in ReactFiberHydrationContext. This happens inside completeWork for each host fiber: React compares the fiber's expected tag/text against the actual DOM node it's standing on. This is fundamentally different from a normal client-side reconciliation, where there's no "existing" DOM to reconcile against — hydration is a special first pass that trusts the server markup until proven wrong.

When completeWork finds a text or attribute mismatch, it calls into throwOnHydrationMismatch, which doesn't unwind like a thrown JS error — it flags the fiber as a hydration error and, depending on severity, either patches what it safely can (some attribute values) or bails on the subtree. A single stray attribute might get silently corrected in dev with a warning; a structural mismatch (wrong tag, wrong text where children differ, a <div> where the server had a <span>) is not patchable, so React discards the server-rendered subtree at that boundary and does a synchronous client render there instead — this is the "tree will be regenerated on the client" language in the error. If that happens above any Suspense boundary or at the root itself, "that subtree" can mean your entire page, which is why one bad typeof window check anywhere near the top of your tree can silently defeat SSR for the whole route.

This ties directly to two other fiber mechanics worth knowing: the current/workInProgress double buffer (there is no "committed" client fiber tree yet during first hydration — refs and layout effects that depend on a stable committed tree don't fire until this pass completes) and lanes/priority (a recovered hydration render is scheduled at a lane that can be interrupted by higher-priority updates, which is part of why React 19.2 added batching to Suspense boundary reveals — to stop hydration mismatches from cascading into visible content-popping as boundaries resolved out of order between server and client).

browser(), new in 19.3, is built directly on this machinery rather than working around it: use(browser(reason)) returns an opaque thenable-like value. On the server, passing it to use() suspends the component — deliberately, using the same mechanism use() already has for async data — so the nearest <Suspense> fallback renders as the SSR output instead of the real (unreliable) browser-only content. In the browser, the same call resolves immediately to undefined and the component renders normally. There is no branch to disagree on, because the server was never asked to guess an answer to a browser-only question — it makes the "don't render this on the server" intent explicit and typed instead of implicit in a typeof window check that both environments happen to evaluate differently.

The Fix

Before — the typeof window branch from the repro, or its cousin, gating on a mounted boolean set from an effect:

tsx

// Before: mismatches on first paint every time
function Greeting() {
  const where = typeof window !== 'undefined' ? 'Client' : 'Server';
  return <span>{where}</span>;
}

Fix 1 — quick, works on any React 18+ version (the classic idiom):

tsx

import { useEffect, useState } from 'react';

function Greeting() {
  const [mounted, setMounted] = useState(false);
  useEffect(() => setMounted(true), []);
  // Renders the SAME thing ("Server") on both passes, then updates after mount.
  return <span>{mounted ? 'Client' : 'Server'}</span>;
}

This works because both the SSR pass and the first client pass render 'Server' — the divergence only happens in a second, post-hydration render, which hydration never sees. Cost: an extra render pass and a visible flash if the two states look different (a real UX cost, not just a lint annoyance).

Fix 2 — correct fix on React ≥ 19.3, no extra render pass:

tsx

import { use, Suspense } from 'react';
import { browser } from 'react-dom';

function Greeting() {
  use(browser('This value only exists in the browser.'));
  return <span>Client</span>; // never runs on the server at all
}

export default function Page() {
  return (
    <Suspense fallback={<span>Server</span>}>
      <Greeting />
    </Suspense>
  );
}

browser() must be called from a Client Component ("use client" in an RSC app) and the call site must sit under a <Suspense> boundary during SSR, or the server render throws instead of falling back — that boundary requirement is the whole point: it forces you to author an explicit, intentional server fallback instead of an implicit guess.

Fix 3 — pre-19.3, for state genuinely sourced from a browser API (localStorage, matchMedia) rather than "just don't render this yet":

tsx

import { useSyncExternalStore } from 'react';

function subscribe() { return () => {}; }
function getSnapshot() { return localStorage.getItem('theme') ?? 'light'; }
function getServerSnapshot() { return 'light'; } // deterministic value the server can commit to

function Theme() {
  const theme = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
  return <span>{theme}</span>;
}

getServerSnapshot is the officially documented answer to "what does the server render instead," and it produces zero hydration warnings because both passes agree by construction.

Suppressing it (know the cost):

tsx

<time suppressHydrationWarning>{new Date().toLocaleTimeString()}</time>

suppressHydrationWarning only silences the warning one level deep and React explicitly will not attempt to patch that node's mismatched content — this is correct for genuinely non-deterministic display values like a live clock where both renders are "right," and wrong for anything else, since it hides real bugs rather than fixing them.

Best Practices & The Better Design

The pattern under every one of these fixes is the same: don't let a component answer a question differently depending on which environment asked it. Push the browser-only branch to the smallest possible leaf component, wrap it in its own <Suspense> boundary with an explicit fallback that is your intended server output (not a spinner standing in for content you didn't think about), and prefer browser()/useSyncExternalStore over hand-rolled mounted state, which is really just a manual re-implementation of what those APIs now do for you with a defined contract. In RSC apps, keep "use client" at the leaves — a Server Component importing something that internally checks typeof window puts the mismatch further up the tree than you think, and per Section 5, mismatches above a Suspense boundary discard more of the page than mismatches below one.

tsx

// The right way, React 19.3+: explicit boundary, explicit fallback, no guessing
import { use, Suspense } from 'react';
import { browser } from 'react-dom';

function LocalTime() {
  use(browser('Formats in the visitor\'s local timezone.'));
  return <span>{new Intl.DateTimeFormat(undefined, { timeStyle: 'short' }).format(new Date())}</span>;
}

export function Clock() {
  return (
    <Suspense fallback={<span>--:--</span>}>
      <LocalTime />
    </Suspense>
  );
}

How to Prevent It Long-Term

Turn on next build's production check and treat any hydration warning in CI as a failing build, not a console curiosity — a Playwright or Cypress smoke test that asserts page.on('console', ...) never fires an "Uncaught Error" during initial load catches this class of bug before it ships. Wire React's onRecoverableError root option (available since 19.0) to Sentry or your APM so hydration recoveries in production surface as a tracked rate, not silence — a climbing hydration-recovery rate is a leading indicator of a regression, since users rarely file a bug report for "the page flickered." Disable CDN/edge auto-minification of HTML responses (Cloudflare's Auto Minify is a known offender) since it can rewrite whitespace in ways that trip the diff. If you're on the React Compiler, eslint-plugin-react-hooks@7's compiler-powered rules — specifically react-hooks/globals (flags mutating globals like window-derived module state during render) and react-hooks/purity (flags known-impure calls, which includes environment-dependent branches) — catch some of this class at lint time rather than in the browser. And test with a real browser extension installed at least once per release; it's the one cause on React's own list you cannot fix in your own code, only detect and route around with suppressHydrationWarning on the specific attributes extensions tend to inject.

This connects directly to a few neighboring failure modes worth knowing: a useId mismatch between server and client bundles running different React patch versions is a hydration mismatch by another name (Section 3's prefix table); useSyncExternalStore without a correct getServerSnapshot is how "tearing" bugs and hydration mismatches turn out to be the same root cause wearing different clothes; and "use client" boundary placement in RSC apps determines exactly how much of the tree a single mismatch takes down with it.

Important

  • "Hydration failed…" (dev) and errors #418/#423 (prod) are the same failure: something the component read gave a different answer on the server than in the browser's first render.
  • React 19 didn't change when this fires — it collapsed the multi-warning React 18 output into one message with a +/- diff, and added onCaughtError/onUncaughtError/onRecoverableError root options to hook into it programmatically.
  • useEffect + mounted state has been the standard workaround since React 18, but it costs an extra render and a visible flash — useSyncExternalStore's getServerSnapshot has been the correct fix for browser-derived state all along.
  • React 19.3 (Sept 2026) added browser(), a first-class use()-based API that suspends browser-only content during SSR instead of letting it render a wrong guess — it needs a <Suspense> boundary and, in RSC apps, a "use client" component.
  • suppressHydrationWarning is a one-level-deep escape hatch for genuinely non-deterministic content (timestamps), not a fix for anything else — it hides the mismatch without correcting it.
Reactreact-errorsreact-domreact-19

From aspiring developer to blogger, I test learning platforms, simplify programming syntax, and share resources that work. Helping you code smarter as I grow myself. New or experienced, you're welcome here.

Comments