Skip to content
serverclient

What React Actually Promises When Hydration Fails (Not What You've Read)

·TechReactHydrationSSR

Ask ten engineers what happens when a hydration mismatch occurs and you’ll get ten different answers, most of them wrong in a specific, checkable way. “React fixes it automatically.” “It’s just a warning, ignore it.” “Add suppressHydrationWarning and you’re done.” None of these are true, and the reason people believe them is that most tutorials on this topic paraphrase the behavior instead of quoting it. Let’s fix that.

The core promise, stated precisely

The hydrateRoot docs state the contract in one sentence: “The React tree you pass to hydrateRoot needs to produce the same output as it did on the server.” That’s it. Hydration isn’t React “attaching” to arbitrary server HTML — it’s React re-running your render on the client and asserting the output matches what was already painted. When it doesn’t match, you’re outside the contract, and everything downstream is best-effort, not guaranteed.

Here’s the sentence that most posts skip entirely, straight from the docs: “There are no guarantees that attribute differences will be patched up in case of mismatches. This is important for performance reasons because in most apps, mismatches are rare, and so validating all markup would be prohibitively expensive.” Read that again — React is not promising to reconcile your DOM for you. It’s promising to warn you in development and then do something reasonable, which is not the same thing.

What “reasonable” actually means

The docs are blunt about the two possible outcomes: “React recovers from some hydration errors, but you must fix them like other bugs. In the best case, they’ll lead to a slowdown; in the worst case, event handlers can get attached to the wrong elements.” That second outcome is the one nobody mentions on Stack Overflow. It’s not a cosmetic flicker — it’s a state where the click handler you attached to button A is now firing on whatever DOM node ended up in that position after React’s reconciliation guessed wrong. If you’ve ever seen a hydration bug where clicking one thing seems to trigger another component’s handler, this is why.

There’s also a more drastic failure mode you’ll see in the console on bigger mismatches: React abandons the subtree it can’t reconcile and re-renders it from scratch on the client, discarding the server HTML for that portion entirely. This is the well-documented “Hydration failed because the server rendered HTML didn’t match the client. As a result this tree will be regenerated on the client” message, which shows up across unrelated projects (Next.js, Mantine, Nextra) using stock React — it isn’t a framework-specific string, it’s core React’s escalation path when a mismatch is too structurally different to patch node-by-node. So there isn’t one hydration failure mode — there’s a spectrum from “silent attribute drift” to “whole-subtree client re-render,” and which one you get depends on how deep and how structural the mismatch is, not on anything you configure.

suppressHydrationWarning does less than people think

This is the one that trips people up the most, because the name sounds like a fix. It isn’t. From the docs: suppressHydrationWarning={true} “only works one level deep, and is intended to be an escape hatch. Don’t overuse it.” And critically: “React will not attempt to patch mismatched text content.” Read that literally — the prop suppresses the warning, not the mismatch. If your server renders “Loading” and your client renders “Ready” in the same text node, setting suppressHydrationWarning doesn’t make React reconcile them for you. The visible text just silently stays whatever the server sent until the next render cycle updates it through normal means. You’ve traded a visible bug report for an invisible bug.

// Wrong assumption: this "fixes" a Date.now() mismatch
function Timestamp() {
  return <span suppressHydrationWarning={true}>{Date.now()}</span>;
}
// Reality: the console warning disappears. The mismatched
// number does NOT get corrected to the client's value by React.
// It just sits there, silently wrong, until something else re-renders it.

The prop is meant for genuinely unavoidable, cosmetically harmless differences — the docs’ own example is a formatted timestamp — where you’ve decided the discrepancy doesn’t matter and you just want the console quiet. It is not a mismatch-fixing tool. If the mismatch matters, you fix the underlying cause.

The actual fix pattern, and its cost

The docs recommend a specific pattern for content that legitimately differs between server and client — not typeof window !== 'undefined' scattered through render logic, but deferring the client-only branch to after mount:

import { useState, useEffect } from "react";

function ClientOnlyBadge() {
  const [isClient, setIsClient] = useState(false);
  useEffect(() => setIsClient(true), []);
  return <span>{isClient ? "Live" : "Static"}</span>;
}

This works because the first client render intentionally matches the server (both render “Static”), and the divergence happens in a second pass, after hydration has already succeeded — not during it. But the docs are upfront that this isn’t free: “This approach makes hydration slower because your components have to render twice.” If you sprinkle this pattern everywhere, you’ve traded hydration correctness for a visibly slower time-to-interactive, and on a slow connection that second render can look jarring rather than seamless.

The documented common causes worth checking against your own code, verbatim from the source: extra whitespace around the root HTML, typeof window !== 'undefined' checks inside render logic, browser-only APIs like window.matchMedia called during render, and rendering different data on the server versus the client (locale-dependent dates, Math.random(), anything time-based). None of these are exotic. They’re exactly the things that creep into a codebase that started server-first and picked up client-only assumptions along the way.

If you’ve worked on static-site-generation-heavy setups — Gatsby, Next.js static export, anything that pre-renders at build time and rehydrates in the browser — this list is not theoretical. It’s the same handful of causes on repeat: a window check that slipped into a shared utility, a date formatted with the visitor’s timezone instead of a fixed one, a third-party script mutating the DOM before React hydrates it. The fix is never “add suppressHydrationWarning and move on.” It’s finding which of the four causes above is actually responsible, because React told you it isn’t going to reconcile that for you.

Sources