Hydration Errors Are Bug Reports, Not Noise

The console says “Hydration failed because the server rendered HTML didn’t match the client.” Someone on the team searches it, finds suppressHydrationWarning, adds it to the <body> tag, and the red text goes away. The ticket is closed.
The bug is not closed. It is just quiet now.
A hydration mismatch is one of the few errors in frontend development that tells you exactly what kind of bug you have: the HTML you shipped and the JavaScript you shipped describe two different pages. One of them is wrong. The warning is the only thing that was going to tell you which.
What hydration actually promises
Hydration is a deal. The server (or, on a static site, the build machine) renders HTML. The browser paints it immediately. Then React loads, renders the same component tree in memory, and walks the existing DOM attaching event handlers instead of creating new nodes.
That only works if both renders produce the same output. React does not diff the two and politely patch the differences. When text content does not match, React gives up on the server HTML for that part of the tree and re-renders it from scratch on the client, up to the nearest Suspense boundary or all the way to the root. When an attribute does not match, React generally leaves the server value in place and moves on, which means the DOM now disagrees with your component state.
So a mismatch costs you one of two things:
- Performance. You paid to render HTML on the server, shipped it, painted it, then threw it away and rendered it again. Your LCP element may get replaced after it paints.
- Correctness. A stale
href,class, oraria-expandedsits in the DOM while React believes something else is there.
Neither shows up in a Lighthouse score. Both show up to users.
On a static site, “the server” is a build box in UTC
Most hydration advice assumes the server render happened milliseconds before the client render. On a static site it happened at build time, which might be three weeks ago, on a CI runner in UTC with an en-US locale and no idea who your reader is.
That widens every gap. Anything that depends on when, where, or who is rendering will diverge:
// Each of these renders differently at build time than in the browser
<span>{new Date().getFullYear()}</span>
<span>{post.date.toLocaleDateString()}</span>
<span>{formatDistanceToNow(post.date)} ago</span>
<span>{price.toLocaleString()}</span>
toLocaleDateString() with no arguments uses the runtime’s default locale and timezone. Node on your CI runner and Chrome on a laptop in Berlin will not agree. The fix is not suppression. The fix is to stop asking the environment:
const fmt = new Intl.DateTimeFormat('en-US', {
dateStyle: 'long',
timeZone: 'UTC',
});
<time dateTime={post.date.toISOString()}>{fmt.format(post.date)}</time>;
Explicit locale, explicit timezone, same output everywhere. If you want the reader’s local format, that is a client-only enhancement, and it should be built as one (more on that below).
The usual suspects
Almost every mismatch traces back to one of these.
1. Branching on the environment during render
function Nav() {
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
return isMobile ? <MobileNav /> : <DesktopNav />;
}
On the server window is undefined, so it renders DesktopNav. On a phone the client render picks MobileNav. React throws away the server HTML and the user watches the nav swap. The same pattern appears with localStorage, navigator.language, matchMedia, and cookies read through document.cookie.
Responsive layout belongs in CSS. Render both and hide one with a media query, or render one structure that adapts.
2. Nondeterministic values
Math.random(), crypto.randomUUID(), and incrementing module-level counters all produce different values on the two renders. The classic victim is a form label:
// Wrong: a different id on server and client
const id = `field-${Math.random().toString(36).slice(2)}`;
// Right: stable across server and client
const id = useId();
useId exists precisely for this. It derives the id from the component’s position in the tree, which is the same on both sides.
3. Invalid HTML nesting
This one surprises people because the code looks fine:
<p>
Read more: <div className="card">{children}</div>
</p>
A <div> cannot live inside a <p>. The browser’s HTML parser does not error. It closes the <p> early, puts the <div> after it, and leaves an empty <p> behind. React then hydrates against a DOM shape that is not what it rendered. The same thing happens with an <a> inside an <a>, a <form> inside a <form>, and a <tr> placed directly in a <table> (the parser inserts a <tbody>).
React flags these in development with a separate “cannot be a descendant of” warning. Treat that warning as a hydration error, because that is what it becomes.
4. Something else edited your HTML
Browser extensions inject attributes onto <html> and <body>. Translation tools rewrite text nodes. Some CDN features rewrite markup on the way out, such as email address obfuscation that swaps a mailto: link for a script-decoded placeholder.
This is the one legitimate use of suppressHydrationWarning, and only on the specific element being touched. It applies to that element’s own attributes and text, one level deep. It does not silence anything in the children. Putting it on <body> to make a deep warning go away does nothing except hide the extension noise you were going to ignore anyway.
Client-only values, done properly
Sometimes you genuinely want a value only the browser knows: the reader’s local time, a saved preference, the viewport. The pattern is to render a deterministic placeholder on the server and swap after hydration:
import { useSyncExternalStore } from 'react';
const subscribe = () => () => {};
// fmt is the UTC Intl.DateTimeFormat from earlier
function LocalTime({ iso }: { iso: string }) {
const local = useSyncExternalStore(
subscribe,
() => new Date(iso).toLocaleString(),
() => null, // server snapshot
);
return <time dateTime={iso}>{local ?? fmt.format(new Date(iso))}</time>;
}
useSyncExternalStore takes a separate server snapshot, and React uses it during hydration so the first client render matches the HTML. Then it re-renders with the real value. No warning, no thrown-away tree, and the UTC fallback is still a correct date for anyone with JavaScript disabled.
The older useEffect plus mounted state trick does the same job with more ceremony. Either way, accept the honest cost: the value changes after paint. If that change is visible and jarring, the value probably should not be on the page until the user asks for it.
For whole components that cannot render without the browser, stop pretending they can. In Next.js, dynamic(() => import('./Map'), { ssr: false }) from a Client Component. In Astro, client:only="react". Both say what they mean: there is no server HTML for this, so there is nothing to mismatch.
Theme is a special case with its own solution, an inline script that sets a class before first paint, covered in Dark Mode Without the Flash.
Make the warning impossible to ignore
The reason hydration bugs survive is that they are loud in development and nearly silent in production. Production React logs a minified error code, the page still works, and nobody looks.
Two cheap fixes.
Fail end-to-end tests on console errors. If you already run Playwright against the built site, one fixture catches every mismatch on every tested page:
import { test as base, expect } from '@playwright/test';
export const test = base.extend({
page: async ({ page }, use) => {
const errors: string[] = [];
page.on('console', (msg) => {
if (msg.type() === 'error') errors.push(msg.text());
});
page.on('pageerror', (err) => errors.push(err.message));
await use(page);
expect(errors, 'console errors during test').toEqual([]);
},
});
Run it with the CI runner’s timezone set to something that is not UTC, such as TZ=America/Los_Angeles, and the browser context’s locale and timezoneId set to something that is not en-US. That reproduces the gap between your build box and your reader instead of hiding it.
Report recoverable errors in production. If you own the root, hydrateRoot accepts an onRecoverableError callback. React calls it when it recovers from a hydration failure by client rendering. Send those to whatever you already use for error collection. A count of zero is the goal, and a nonzero count with a component stack is a bug with a stack trace attached.
The takeaway
suppressHydrationWarning is not an off switch for an annoying message. It is a narrow tool for one element whose content you knowingly let something else control.
Every other mismatch is a real disagreement between two renders of your page, and the fix is always the same shape: make the render deterministic, or make the client-only part explicitly client-only. Pin the locale and timezone. Use useId. Put layout decisions in CSS. Fix the HTML nesting. Then make your tests fail when the warning comes back, because it will.

