How to Fix Hydration Mismatches That Cost Performance

This guide addresses a correctness bug with performance consequences, within Hydration & Partial Rehydration Strategies and JavaScript Bundle Optimization & Code Splitting. Hydration assumes the client's first render produces exactly the HTML the server sent; the framework then attaches event handlers to existing DOM instead of recreating it. When the two renders differ — a mismatch — frameworks recover by patching or re-rendering. React 18+ may discard the server HTML for the affected Suspense boundary (or the whole root) and render it again on the client; Vue patches the mismatched nodes and warns.

Recovery is not free. Re-rendering a region means creating DOM on the client after it was already painted: extra main-thread work during the busiest moment of the page load, possible layout shifts if the new content differs in size, and a new LCP candidate if the re-rendered region contains the LCP element. Teams often see the console warnings, decide the page "looks fine", and never connect them to worse field metrics.

Hydration with a mismatch in the hero region Timeline showing server HTML painted, hydration detecting a mismatch, and the client re-rendering the hero, creating a later LCP and a layout shift. Hydration with a mismatch in the hero region Paint server HTML painted Main thread hydrate client re-render Metrics 0ms 400ms 800ms 1200ms 1600ms 2000ms 2400ms

Rapid Diagnosis

  • Read the console in development. React: "Hydration failed because the server rendered HTML didn't match the client" or "Text content does not match"; Vue: "Hydration node mismatch" / "Hydration text content mismatch".
  • Check production too. Production builds report less detail; React 19 logs recoverable errors via onRecoverableError on hydrateRoot. Wire it to your error tracker.
  • Correlate with LCP and CLS attribution. If the LCP element or the largest shift source is inside a region that logs mismatches, they are connected.
  • Trace the hydration task. In a throttled trace, a second large render after hydration indicates recovery work.

Root Cause Analysis

1. Time- and locale-dependent output. new Date().toLocaleString(), relative times ("3 minutes ago"), and number formatting that depends on the browser's locale differ between server and client.

2. Browser-only state. Reading window.innerWidth, localStorage, matchMedia or cookies during render produces different output on the server (where they are unavailable).

3. Randomness and IDs. Math.random() keys, non-deterministic IDs, or shuffled content.

4. Invalid HTML nesting. <div> inside <p>, nested <a> tags or <table> without <tbody> are "corrected" by the browser's parser, so the DOM the client hydrates against differs from what the framework expects.

Common mismatch causes and fixes Mapping of common hydration mismatch causes to their fixes. Common mismatch causes and fixes Cause Example Fix Time / locale toLocaleString() in render format on server, pass string Browser APIs in render window.innerWidth, localStorage read in effect after mount Random values Math.random() keys or IDs useId / stable keys Invalid nesting div inside p fix the markup

Step-by-Step Resolution

1. Make time and locale output deterministic

jsx
// Before: server and client format differently.
<time>{new Date(post.publishedAt).toLocaleDateString()}</time>

// After: format once with an explicit locale and time zone, on the server.
const published = new Intl.DateTimeFormat('en-GB', { dateStyle: 'medium', timeZone: 'UTC' }).format(new Date(post.publishedAt));
<time dateTime={post.publishedAt}>{published}</time>
// trade-off: a fixed time zone may not match the reader's. If local time
// matters, render the UTC value on the server and update it after mount —
// keeping the element's size stable to avoid a shift.

Expected outcome: identical server and client output for dates.

2. Move browser-only reads into effects

jsx
function Sidebar() {
  const [collapsed, setCollapsed] = useState(false);             // same on server and first client render
  useEffect(() => setCollapsed(localStorage.getItem('sidebar') === '1'), []);
  return <aside className={collapsed ? 'collapsed' : ''}>…</aside>;
}
// trade-off: users with a stored preference see the default for one frame,
// then the stored state. Store the preference in a cookie instead so the
// server can render it correctly from the start.

Expected outcome: no mismatch; any adjustment happens as a normal client update.

3. Use stable IDs and keys

Use useId() (React) or framework-provided ID helpers for accessibility IDs, and data IDs for list keys. Never Math.random() during render.

4. Fix invalid HTML

Run an HTML validator on server output, or watch for nesting warnings in development. Browser parser corrections are invisible in the source but produce mismatches every time.

Clearing hydration mismatches Four steps to find and eliminate hydration mismatches in production. Clearing hydration mismatches Report recoverable errors in production onRecoverableError / app.config.errorHandler to your tracker Group by component and template Fix the highest-traffic regions first Make render deterministic Fixed locale/time zone, stable IDs, no browser APIs in render Validate server HTML Catch parser-corrected nesting before it ships 1 2 3 4

Verification

The console should be free of hydration warnings in development and the error tracker free of recoverable hydration errors in production. In a throttled trace, there should be one hydration pass with no follow-up re-render. If the mismatch was in the LCP region, field LCP should drop; if it caused shifts, CLS attribution should stop naming that region.

Worked Example: A "Last Updated" Label in the Hero

A product page rendered "Updated 3 minutes ago" in its hero using the current time. Server and client differed by the seconds between render and hydration, so React discarded the hero's server HTML and re-rendered it — including the hero image element, which became a new LCP candidate after hydration. Field LCP on the template was 2.9s, with the LCP timestamp frequently landing just after hydration finished. Rendering an absolute timestamp on the server and updating the relative label in an effect fixed the mismatch; LCP p75 dropped to 2.2s and a recurring 0.04 layout shift disappeared.

Common Mistakes

  • Silencing warnings with suppressHydrationWarning. It hides one level of text mismatch and is meant for unavoidable cases like timestamps — not structural differences.
  • Rendering different components on server and client based on typeof window.
  • Ignoring warnings because "it works". Recovery work is invisible on fast machines and costly on slow ones.
  • Personalisation in render without server knowledge. If the server cannot know the value, render a neutral placeholder of the same size.

Edge Cases

Browser extensions. Extensions that modify the DOM before hydration (password managers, translators) cause mismatches you cannot fix; filter them in error reporting by checking for known injected attributes.

CDN HTML rewriting. Some CDNs or proxies rewrite HTML (email obfuscation, script injection), changing the DOM before hydration. Disable such features for framework-rendered pages.

A/B testing scripts that change server-rendered content before hydration produce mismatches and often flicker; run experiments server-side.

Streaming with Suspense. In React, mismatches inside a Suspense boundary only re-render that boundary, limiting the damage — another reason to use boundaries around independent regions.

Building Mismatch Detection into CI

Mismatches tend to be introduced by small, innocent changes — a new date label, a feature flag read in render — so catch them before release. Run an end-to-end test that loads key pages in a real browser with the production build, listens for console errors and recoverable-error reports, and fails on any hydration message. Run it under a different locale and time zone from your CI server's (Playwright's locale and timezoneId options make this easy): many mismatches only appear when the client's environment differs from the server's, which is exactly the situation of real users and never the situation of a developer testing locally.

FAQ

Does a mismatch always cause a full re-render?

No. React re-renders the nearest Suspense boundary containing the mismatch (or the root if there is none); Vue patches the specific mismatched nodes. The cost scales with the size of the affected region, which is why boundaries help.

Is suppressHydrationWarning ever appropriate?

For a single text node that legitimately differs, such as a timestamp, it avoids a warning and recovery for that node. It does not apply to children or attributes beyond one level, and it should never be used to hide structural differences.

How do I find mismatches that only happen for some users?

Report recoverable hydration errors from production with context (route, locale, time zone, user agent). Patterns like "only users in UTC+10" or "only Safari" point directly at locale, time zone or engine-specific parsing differences.

Do mismatches affect SEO?

Search engines see the server HTML; mismatches affect what users see after hydration and performance metrics like LCP and CLS, which do feed into page experience assessments.

Can islands architectures have mismatches?

Yes, within each island. The blast radius is smaller because only that island re-renders, but the same causes apply.