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.
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
onRecoverableErroronhydrateRoot. 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.
Step-by-Step Resolution
1. Make time and locale output deterministic
// 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
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.
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.
Related
- Fixing slow LCP in Vue and Nuxt apps — mismatches as an LCP cause in Vue.
- Why the LCP element changes between loads — how re-renders create new candidates.
- Selective hydration with React Suspense — limiting the blast radius with boundaries.