Skip to content

    Frontend System Design

    Debugging Hydration Mismatches

    Find and fix hydration mismatches: learn what React compares, why text, attributes and nesting differ, and which fixes really work, with a lab you can rerun.

    By Published 8 min read

    • frontend-system-design
    • hydration
    • ssr
    • debugging
    • react
    Share

    Focused lessonintermediateReact labFrontend System Design

    Before this: Hydration, Selective Hydration and Islands

    What you'll learn: By the end of this lesson you will be able to explain what a hydration mismatch is, predict whether React reports it as an error or only a warning and what it does to the DOM, name the common causes (clock, window, random values, invalid nesting, something rewriting the HTML), and choose the right fix for each instead of silencing the warning.

    Hydration is a spot-the-difference puzzle with a strict rule: the picture the server drew and the picture the browser would draw must be identical. When they are not, React does not guess which one is right. It tells you, and it usually throws one of them away.

    The running example is the product page from Hydration, Selective Hydration and Islands: a greeting that depends on the hour of day, plus an "Add to cart" button. The server is at 9 o'clock and the visitor's browser is at 17. That one difference produces most of the behaviour in this lesson.

    The core idea

    1. Hydration compares the server's HTML with what the components render in the browser, and expects no difference. react.dev says to "treat mismatches as bugs and fix them".
    2. A difference has two outcomes. Some are reported as errors and make React rebuild part of the tree on the client; others are only warnings and leave the server's markup in place.
    3. The cause is almost always an input that differs between the two environments (time, locale, window, a random value), markup the HTML parser rewrites, or something that changes the HTML before React sees it.
    4. The fix is to make the first browser render match, then show the browser-only value afterwards. Silencing the warning is not a fix.

    Version note: the lab ran React 19.3.1 (the latest minor on react.dev at the time of writing). Warning texts are development messages, not an API.

    What React does with a difference

    The lab renders each case on the server with no window, parses the HTML in jsdom (a model of the DOM, not a browser) and calls hydrateRoot. What it observed:

    Case What differs React reports DOM afterwards
    Clock (time-text) text, 9 versus 17 recoverable error: "server rendered text didn't match the client" rebuilt on the client, shows the browser's text
    typeof window text the same recoverable error rebuilt, shows the browser's text
    Random attribute data-token value warning only, no error server's value kept
    div inside p structure, after the parser recoverable error, plus "In HTML, <div> cannot be a descendant of <p>" rebuilt
    suppressHydrationWarning text nothing server's text kept

    Figure 1. What React does with a difference

    flowchart TD accTitle: How React handles a hydration difference accDescr: If the browser's first render equals the server HTML, React attaches to the existing DOM. If text or structure differs, React reports a recoverable error and renders that part on the client. If only an attribute differs, React warns in development and keeps the server value. With suppressHydrationWarning on an element, the warning is silenced and the text is not patched. A["Browser's first render
    vs server HTML"] --> B{"Same?"} B -->|"yes"| C["Attach: DOM nodes reused"] B -->|"no"| D{"What differs?"} D -->|"text or structure"| E["onRecoverableError;
    part rebuilt on the client"] D -->|"attribute value"| F["Development warning;
    server value stays"] D -->|"marked suppressHydrationWarning"| G["No warning;
    text not patched"]

    How to read this: the same bug can look loud or silent. A text difference rebuilds the DOM and is reported; an attribute difference leaves a wrong value in the page and says little.

    Two more observations from the lab. A difference inside a Suspense boundary rebuilt only that boundary: the button outside it kept its DOM node. And after a rebuild the page still worked (a click on the button was handled), which is why mismatches often go unnoticed. react.dev's warning is that "in the best case, they'll lead to a slowdown; in the worst case, event handlers can get attached to the wrong elements". The documentation also says there is no guarantee that attribute differences are patched up.

    Where the differences come from

    Figure 2. The places a page can change between server and hydration

    Server render, proxies, the browser's parser, extensions and hydrationServer render to CDN or proxy (HTML); CDN or proxy to HTML parser (HTML); HTML parser to hydrateRoot (DOM); Extensions to hydrateRoot (changes)BROWSERServer render: no window, server clockServer renderno window, serverclockCDN or proxy: may rewrite HTMLCDN or proxymay rewrite HTMLHTML parser: fixes invalid nestingHTML parserfixes invalidnestingExtensions: add nodesExtensionsadd nodeshydrateRoot: browser clock, windowhydrateRootbrowser clock,windowHTMLHTMLDOMchanges

    How to read this: every amber box can make the DOM React finds differ from the HTML the server rendered. The two ends differ too: the server has no window and its own clock.

    • Inputs that differ. Time, time zone, locale, Math.random(), typeof window !== 'undefined' branches and browser-only APIs such as window.matchMedia in render code. react.dev lists the last three; the Next.js page adds Date() and localStorage.
    • Invalid nesting. React's server output for <p> containing <div> is <p>…<div>…</div></p>. The browser's HTML parser closes the paragraph at the div; the lab's parsed DOM was <p>Free returns </p><div>within 30 days</div><p></p>. MDN notes that a paragraph "will automatically close if another block-level element is parsed". The Next.js page lists <p> in <p>, <div> in <p> and nested <a> or <button> too.
    • HTML rewritten in transit. Next.js lists CDN features such as Cloudflare Auto Minify, and iOS turning phone numbers into links.
    • Foreign nodes. The React 19 announcement says unexpected tags in <head> and <body> "will be skipped over". In the lab, a node added to <body> before whole-document hydration caused no report and stayed; the same node inside the page's own <main> caused a recoverable error and was gone afterwards.

    Finding the cause

    Read the development message: React 19 logs one error with a diff, client value marked + and server value -. In production, pass onRecoverableError to hydrateRoot and send the first sentence to monitoring with the route. Then reproduce in a test with the server's and the browser's inputs as parameters, as the lab does.

    Choosing the fix

    Figure 3. Choosing a fix

    flowchart TD accTitle: Choosing a fix for a hydration mismatch accDescr: If the value exists only in the browser and may appear after load, render a placeholder first and update in an effect. If it must be right on the first browser render, read it through useSyncExternalStore with getServerSnapshot. Ids come from useId. Invalid nesting is fixed in the markup. An unavoidable single text difference may use suppressHydrationWarning. A["What differs?"] --> B{"A value only the
    browser knows"} B -->|"can appear after load"| C["Render the same first,
    update in an effect"] B -->|"read from an external store"| D["useSyncExternalStore
    with getServerSnapshot"] A --> E{"A generated id"} E --> F["useId"] A --> G{"Invalid nesting"} G --> H["Fix the markup"] A --> I{"One unavoidable
    text difference"} I --> J["suppressHydrationWarning
    on that element"]

    How to read this: pick the branch that matches the cause. The last branch only hides the report; the lab shows the server's text stays on screen.

    The first two fixes, as the lab runs them (both ended on the browser's value with no report and the button attached):

    export function EffectGreeting({ world }: { world: World }): ReactElement {
      const [hour, setHour] = useState<number | null>(null);
      useEffect(() => setHour(world.hour), [world.hour]);
      const text = hour === null ? "Hello" : hour < 12 ? "Good morning" : "Good afternoon";
      return h(Shell, null, h("p", null, text));
    }
    export function StoreGreeting({ world }: { world: World }): ReactElement {
      const hour = useSyncExternalStore(
        () => () => undefined, // nothing ever changes in this lab, so there is nothing to subscribe to
        () => world.hour, // what the browser reads after hydration
        () => 12, // what the server renders and what hydration uses first (the same on both sides)
      );
      return h(Shell, null, h("p", null, hour < 12 ? "Good morning" : "Good afternoon"));
    }

    react.dev notes that the effect pattern makes hydration slower because components render twice. For useSyncExternalStore, its reference says the server snapshot must be the same on the client and the server. suppressHydrationWarning works one level deep and is an escape hatch: "React will not attempt to patch mismatched text content".

    Three illustrations

    Illustration 1, the minimal example. The clock greeting (Figure 4): the server says "Good morning", the browser would say "Good afternoon". Result: one recoverable error and a rebuilt DOM.

    Figure 4. The clock mismatch, step by step

    sequenceDiagram accTitle: The clock mismatch accDescr: The server renders Good morning at hour 9 and sends HTML. The browser parses it. hydrateRoot renders at hour 17, gets Good afternoon, finds a difference, reports a recoverable error and renders the part on the client. participant S as Server (hour 9) participant B as Browser (hour 17) participant R as React S-->>B: HTML with "Good morning" B->>R: hydrateRoot R->>R: render gives "Good afternoon" R->>B: onRecoverableError, rebuild part on the client

    How to read this: React compares its own first render with the HTML, finds one text node different, and prefers its own.

    Illustration 2, the realistic example. The product page's delivery estimate, computed from the clock and the visitor's locale, sits next to the cart button. A fixed placeholder on both sides, filled in an effect, gives no error and keeps the button attached, as the effect-two-pass scenario shows.

    Illustration 3, the failure case. suppressHydrationWarning on the greeting: no warning, and the page keeps "Good morning" while a browser-only render shows "Good afternoon". The report is gone and the bug is not.

    Build it: reproduce each mismatch

    Goal. Predict, then read, what React reports and what the DOM contains for thirteen cases.

    cd examples/frontend
    pnpm install
    cd labs/hydration-mismatches
    pnpm test          # 19 tests
    node src/demo.ts   # prints the scenarios

    Expected output for two scenarios, from a real run (Node 26.12.1, React 19.3.1, jsdom 31.0.0, 2026-11-08):

    === time-text
    note: Bug: the clock reads 9 on the server and 17 in the browser, so the text differs.
    server HTML:          <p>Good morning</p>
    after hydration:      <p>Good afternoon</p>
    browser-only render:  <p>Good afternoon</p>
    after hydration equals a browser-only render: yes
    onRecoverableError:   "Hydration failed because the server rendered text didn't match the client."
    console.error:        nothing
    first button:         a new DOM node (client rendered)
    after one click:      Add to cart (1)
    === random-attribute
    note: Bug: an attribute value is drawn at render time and differs.
    server HTML:          <p data-token="k7f3">Order reference</p>
    after hydration:      <p data-token="k7f3">Order reference</p>
    browser-only render:  <p data-token="q2m8">Order reference</p>
    after hydration equals a browser-only render: no
    onRecoverableError:   not called
    console.error:        "A tree hydrated but some attributes of the server rendered HTML didn't match the client properties."
    first button:         same DOM node (attached)
    after one click:      Add to cart (1)

    This lab is not a browser. It uses jsdom's HTML parser, not a real browser's, and React's development build. It shows what React reports and the resulting DOM; it says nothing about speed, appearance, real extensions or production builds.

    Break it. In src/scenarios.ts, change the time-text client from browserWorld to serverWorld. The environments agree, nothing is reported, and two tests fail. The bug was an input that differs. Revert it.

    In practice: a delivery estimate on a cached product page

    This is a realistic scenario, not a case history.

    The situation

    A product page shows "Arrives Tuesday" next to the cart button. The server computes it from its clock and the visitor's country, and the HTML is cached for several minutes.

    Starting architecture

    Server render, a cache, the browser's parser and one hydration pass, as in Figure 2. The estimate is plain text computed during render.

    Options considered

    Option Result Risk
    suppressHydrationWarning quiet stale text stays on screen
    Render a placeholder, fill in an effect no mismatch one extra render; reserve the space
    Compute on the server in UTC, format in the browser consistent still needs the browser step

    The decision

    Render a fixed-width placeholder on both sides and fill the estimate after hydration, so the cache can keep serving one HTML document.

    Rollout and verification

    Add a test with a server world and a browser world that asserts no onRecoverableError call. Send onRecoverableError to monitoring tagged by route.

    The 2 a.m. failure

    A tag-manager script starts inserting a banner inside <main> before hydration. The recoverable-error rate rises; React rebuilds the tree and drops the banner. The diff in the message points at the inserted node. The fix is to insert the banner after hydration, outside React's markup.

    Architect's notes

    • Treat a recoverable error as a defect with an owner, not noise.
    • Keep server-only and browser-only values out of render; pass them in.
    • Test with two environments, not one.

    Common mistakes

    • Silencing the warning instead of removing the difference.
    • Branching on typeof window in render.
    • Using Math.random() for ids; use useId.
    • Putting block elements inside <p>.

    Key takeaways

    • A mismatch is a difference between server HTML and the browser's first render.
    • Text and structure differences are reported and rebuilt; attribute differences only warn and stay wrong.
    • Causes: differing inputs, parser fix-ups, rewritten HTML, foreign nodes.
    • Fix by matching the first render, then updating after mount.
    • suppressHydrationWarning hides, it does not repair.

    Practice exercises

    Exercise 1, easy (20 minutes). Run the lab and list which scenarios call onRecoverableError and which only warn.

    Exercise 2, intermediate (45 minutes). Add a locale-formatted date scenario and fix it with the two-pass pattern.

    Exercise 3, intermediate (60 minutes). Make a test in your own app fail when hydration reports a recoverable error.

    Official documentation

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Get new articles by email

    Free. We email a confirmation link first, and you can unsubscribe from any email.

    Join engineers building production AI systems

    Comments