Avoiding Hydration Mismatch With Lazy Components
The console warning appears only in production, only sometimes, and always on a route that server-renders:
Warning: Text content did not match. Server: "Quarterly revenue" Client: ""
at ReportPanel
at Suspense
at DashboardRoute
Warning: An error occurred during hydration. The server HTML was replaced
with client content in <div>.
The second warning is the expensive one. React did not patch the difference — it threw away the server-rendered subtree and re-rendered it from scratch on the client, discarding exactly the work server rendering exists to provide.
The general SSR splitting constraints are covered in code splitting for SSR and React Server Components. This page is the specific interaction between lazy boundaries and hydration.
Root cause: the client’s first render happens before the chunk arrives
Hydration is a comparison. The server produced HTML; the client renders the same tree and expects to find matching DOM. That comparison happens once, at a specific moment — and for a lazily-loaded component, whether its code is present at that moment is a race.
On the server, React.lazy resolves before the HTML is produced, so the real component’s markup ships. On the client, hydration begins as soon as the entry and route chunks have evaluated. If the lazy component’s chunk has not arrived by then, the client’s first render produces the Suspense fallback, which does not match the server’s markup.
Fix 1: preload the chunks the server actually rendered
If the server rendered a lazy component, the client needs that component’s chunk before hydration, not after. Frameworks that integrate splitting with SSR track which lazy components were used during the render and emit the corresponding hints; without one, collect them yourself.
// server/render.js — emit modulepreload for every lazy chunk the render used
import { renderToString } from 'react-dom/server';
export function renderPage(App, manifest) {
const used = new Set();
// The loader records each lazily-imported module id during the render.
const html = renderToString(<ChunkCollector onUse={(id) => used.add(id)}><App /></ChunkCollector>);
const hints = [...used]
.flatMap((id) => manifest[id] || [])
// modulepreload, not preload: it also fetches the module's own imports,
// so the whole subtree is present before hydration begins.
.map((file) => `<link rel="modulepreload" href="/assets/${file}">`)
.join('');
return `<!doctype html><html><head>${hints}</head><body><div id="root">${html}</div></body></html>`;
}Use modulepreload rather than preload for module chunks: it primes the module graph including static dependencies, and it avoids the attribute-mismatch trap described in fixing unused preload warnings in Chrome DevTools.
Fix 2: make client-only components genuinely client-only
For a component that has no business being in the server HTML — a chart below the fold, an editor behind a button — the right answer is not to preload it but to exclude it from the server render entirely, so there is nothing to mismatch.
// ClientOnly.jsx — React 18+: render nothing on the server, mount after hydration
import { useEffect, useState } from 'react';
export function ClientOnly({ children, placeholder }) {
const [mounted, setMounted] = useState(false);
// Effects do not run on the server, and this one runs AFTER the first
// client render — so the first client tree matches the server tree exactly.
useEffect(() => setMounted(true), []);
return mounted ? children : placeholder;
}The placeholder must be identical on both sides — same element, same dimensions, ideally the same reserved box discussed in designing Suspense fallbacks that avoid layout shift. Rendering null on the server and a skeleton on the client is the same mismatch in a different costume.
Meta-frameworks expose this directly:
// Next.js — the framework's own client-only escape hatch
import dynamic from 'next/dynamic';
const RichTextEditor = dynamic(() => import('../components/RichTextEditor'), {
ssr: false, // never rendered on the server
loading: () => <div style={{ height: 420 }} />, // same box on both sides
});Fix 3: remove environment-dependent output from lazy components
A third class of mismatch has nothing to do with chunk timing: the component renders different content on server and client because it reads something environment-specific — a timestamp, a locale-formatted number, a random id, a viewport width.
// Wrong: the server's clock and the client's clock are never identical.
// <span>{new Date().toLocaleTimeString()}</span>
// Right: render a stable value, then upgrade after mount.
function LocalTime({ iso }) {
const [local, setLocal] = useState(null);
useEffect(() => setLocal(new Date(iso).toLocaleTimeString()), [iso]);
// The server and the first client render both produce the ISO string.
return <time dateTime={iso}>{local ?? iso}</time>;
}Locale formatting deserves particular care: the server’s default locale is frequently not the user’s, and the resulting difference in date order or decimal separator is a mismatch that produces subtly wrong output rather than a crash — the same class of defect described in replacing Moment.js with date-fns or Temporal.
Step-by-step verification
-
Reproduce with throttling. Serve a production build and throttle the network. The mismatch is timing-dependent, so it often will not reproduce on a fast connection.
-
Diff the two trees. Compare the server HTML for the region against the client’s first render output. The warning names the component; the diff names the cause.
-
Confirm the preload hints exist. View source and check for a
modulepreloadtag for each lazy chunk the server rendered. -
Confirm hydration keeps the markup. With React DevTools, verify the region is hydrated rather than remounted — a remount means the mismatch is still present.
-
Test client-only components on both sides. Confirm the server output and the first client render are byte-identical for the placeholder.
-
Watch the field metrics. A resolved mismatch usually shows up as a reduction in hydration cost, visible in the interaction attribution described in measuring INP impact of lazy hydration.
Edge cases and gotchas
Streaming server rendering. With streamed HTML, a Suspense boundary can flush its fallback and then its content, which is normal and not a mismatch. Distinguish a streamed placeholder from a hydration divergence before chasing it.
Conditional rendering on viewport width. A component that renders differently above and below a breakpoint mismatches whenever the server’s assumed width differs from the client’s. Use CSS for responsive differences rather than branching in the render.
Third-party components mutating the DOM. A library that manipulates DOM during mount can make the hydrated tree differ from what React expects on a subsequent render. Isolate those inside a client-only boundary.
Development-only warnings. Production builds suppress the detailed warnings, so an unnoticed mismatch in production is still discarding markup silently. Test with a development build before assuming the problem is gone.
FAQ
Why does a lazy component cause a hydration mismatch at all?
Because the server and the client can disagree about what the region contains at the moment hydration runs. The server renders the real component into HTML; the client begins hydrating before the component’s chunk has arrived, so its first render produces the fallback instead. The two trees differ, and the reconciler reports a mismatch. Nothing is wrong with the component — the disagreement is about timing.
Should lazily-loaded components render on the server at all?
It depends on whether the content matters for the initial paint or for search indexing. Content that does should be server-rendered, with its chunk preloaded so hydration has it in time. Content that does not — a chart below the fold, an editor behind a button — is better excluded from the server render entirely, which removes both the mismatch risk and the server-side cost of rendering something nobody sees immediately.
Is suppressing the hydration warning ever the right fix?
Only for content that is genuinely and intentionally different between server and client, such as a rendered timestamp or a locale-formatted value. Suppression silences the report without changing the underlying divergence, so applying it to a lazy-component mismatch hides a real problem: the client is still discarding server-rendered markup and re-rendering the region, with all the cost that implies.
Related
- Code Splitting for SSR and React Server Components — the parent guide on server and client bundle boundaries
- Streaming SSR and Selective Hydration With Code Splitting — how streaming changes what a fallback in the HTML means
- Using Dynamic import() in React Server Components — drawing the server/client boundary correctly in the first place
- Designing Suspense Fallbacks That Avoid Layout Shift — making the placeholder identical on both sides