Hydration Mismatches in Federated React Apps #
React silently discards server markup when the client tree disagrees with it, and in a federated application the usual cause is not a date or a random id — it is two different builds of the same remote.
That distinction changes the whole debugging approach. The familiar advice (avoid Date.now(), avoid Math.random(), guard window) still applies, but it will not find the federated cases, which come from version drift, share-scope differences and per-request state. This page covers both, starting with the federation-specific ones. It builds on server-side rendering federated modules.
Prerequisites #
- React 18+ with
hydrateRoot, and a Node host rendering remotes as in configuring Node federation for SSR remotes. - Both build targets published per remote, under a single version.
- A development build available in staging — React’s production build reports mismatches without useful detail.
Step 1 — Confirm it is actually a mismatch #
React 18 recovers from most mismatches by discarding the server HTML and client-rendering the subtree. The page looks correct, which is why this goes unnoticed for months.
// client/index.jsx — make recovery visible instead of silent
import { hydrateRoot } from 'react-dom/client';
hydrateRoot(document.getElementById('root'), <App />, {
onRecoverableError(error, errorInfo) {
navigator.sendBeacon('/rum', JSON.stringify({
metric: 'hydration-recovered',
message: String(error?.message ?? error),
componentStack: errorInfo?.componentStack?.slice(0, 800),
route: location.pathname,
}));
},
});
onRecoverableError is the only reliable signal in production. Without it, a mismatch costs you every benefit of server rendering and produces no error anyone will see.
The component stack in the report is what tells you which remote is involved, so it is worth keeping even though it is verbose.
Step 2 — Rule out version drift first #
In a federated application this is the most common cause and the easiest to check. If the server rendered version 4.2.1 and the client loaded 4.3.0, every difference between those builds is a mismatch.
// remote/src/Widget.jsx — stamp the build into the markup
const BUILD = process.env.REMOTE_VERSION; // injected by DefinePlugin in both targets
export default function Widget(props) {
return <section data-remote="cart" data-build={BUILD}>{/* … */}</section>;
}
// client — compare what the server stamped against what actually loaded
document.querySelectorAll('[data-remote]').forEach((el) => {
const rendered = el.dataset.build;
const loaded = window.__REMOTES__?.[el.dataset.remote]?.version;
if (rendered && loaded && rendered !== loaded) {
console.error(`[federation] ${el.dataset.remote}: server ${rendered}, client ${loaded}`);
}
});
When these disagree, the fix is not in the component — it is in resolution. The server must serialise the versions it used into the HTML and the client must read them from there rather than resolving the manifest independently.
Step 3 — Check the share scope resolved the same way #
Two environments can resolve the same shared dependency differently. The server has a warm share scope populated by whichever remote was evaluated first; the browser populates it in load order for that page.
// A probe rendered by the host in development, on both sides
export function ShareScopeProbe() {
const versions = Object.fromEntries(
Object.entries(__webpack_share_scopes__.default ?? {})
.map(([name, entry]) => [name, Object.keys(entry)]),
);
if (typeof window === 'undefined') {
console.log('[server share scope]', versions);
} else {
console.log('[client share scope]', versions);
}
return null;
}
If a library appears with different versions on the two sides, a component that renders anything version-dependent will mismatch. This is the practical reason strictVersion: true is more important on the server than in the browser: it converts a silent divergence into a loud failure at load time.
Step 4 — Isolate per-request state that leaked into module scope #
A module-level variable on the server is shared across requests. When it holds request data, the server renders one user’s value into another user’s HTML, and the client — which has the correct value — replaces it.
// WRONG — module scope on a shared Node process
let cachedUser;
export function useUser() {
cachedUser ??= fetchUserSync();
return cachedUser;
}
// RIGHT — resolved per request, identical on both sides
import { createContext, useContext } from 'react';
export const UserContext = createContext(null);
export const useUser = () => useContext(UserContext);
The tell-tale symptom is a mismatch that only appears under concurrent load and never in local development, where requests arrive one at a time. The concurrency test in configuring Node federation for SSR remotes catches it deterministically.
Step 5 — Handle remotes that only exist on the client #
A remote with no server build has to render something on the server, and that something must be byte-identical to what the client renders on its first pass.
// src/ClientOnlyRemote.jsx
import { useSyncExternalStore } from 'react';
const subscribe = () => () => {};
// Returns false during SSR and on the client's first render, true after.
const useHydrated = () =>
useSyncExternalStore(subscribe, () => true, () => false);
export function ClientOnlyRemote({ height, children }) {
const hydrated = useHydrated();
return (
<div style={{ minHeight: height }}>
{hydrated ? children : <Placeholder height={height} />}
</div>
);
}
useSyncExternalStore with distinct client and server snapshots is the correct primitive here. A useEffect-based flag works too, but this version makes the intent explicit and cannot accidentally read true during the first client render.
The placeholder must be deterministic — no timestamps, no random ids, no locale-dependent formatting — because it is the thing React compares against the server output.
Step 6 — Normalise the inputs both sides render from #
Even at the same version, the two environments differ in ways that surface through formatting.
// The server's locale and timezone are the machine's; the client's are the user's.
const formatter = new Intl.DateTimeFormat('en-GB', {
timeZone: 'UTC', // pin it — never rely on the ambient zone
dateStyle: 'medium',
});
Three normalisations remove most remaining mismatches. Pin the timezone rather than inheriting it, because the server is almost always UTC and the user is almost never. Pass locale explicitly from the host to every remote as part of the init contract, rather than letting each side detect it. And generate ids with React’s useId instead of a counter, since a module-level counter on the server continues from wherever the previous request left it.
// A module-level counter is per-process on the server and per-page on the client.
const id = useId(); // stable across server and client, unique per instance
Step 7 — Gate mismatches in CI #
Catching this before release is straightforward once onRecoverableError is wired.
// test/hydration.spec.mjs
import { test, expect } from '@playwright/test';
const ROUTES = ['/', '/checkout', '/account', '/browse/shoes'];
for (const route of ROUTES) {
test(`no hydration mismatch on ${route}`, async ({ page }) => {
const problems = [];
page.on('console', (msg) => {
const text = msg.text();
if (/hydrat|did not match|server HTML/i.test(text)) problems.push(text);
});
await page.goto(process.env.PREVIEW_URL + route, { waitUntil: 'networkidle' });
expect(problems, problems.join('\n')).toHaveLength(0);
});
}
Run it against a development build in staging so React’s messages carry the diff. Against a production build the test still detects the mismatch but tells you almost nothing about where it is.
Verification #
- Recovery is reported: deliberately render
{Date.now()}in a remote, load the page, and confirm ahydration-recoveredbeacon appears. - Versions agree: compare
data-buildin the served HTML againstwindow.__REMOTES__. They must match for every remote on the page. - Server markup survives: in the Elements panel, a server-rendered remote should keep its DOM nodes after hydration. Watch for the whole subtree being replaced.
- Share scopes match: with the probe from step 3 enabled, the two logged objects should be identical.
- Concurrency is safe: run the two-user concurrent render test and confirm neither response contains the other’s data.
Troubleshooting #
Symptom: the mismatch only happens in production.
Diagnosis: production deploys often enough that a release lands between the server render and the client boot. Fix: serialise the resolved versions into the HTML so the client cannot pick up a newer build mid-page-load.
Symptom: text content differs by exactly one character or a space.
Diagnosis: a formatter with different defaults on the two sides — usually a non-breaking space in a currency or date format under a different locale. Fix: pass locale and timezone explicitly and construct formatters with all options specified.
Symptom: React reports a mismatch but the DOM looks identical.
Diagnosis: attribute order or whitespace differences from a remote that renders raw HTML with dangerouslySetInnerHTML. Fix: normalise the HTML on the server before injecting it, or move the region behind the client-only boundary from step 5.
Symptom: mismatch appears only for logged-in users.
Diagnosis: per-request state in module scope, so a warm process serves stale user data. Fix: move the state into context or AsyncLocalStorage, and add the concurrency test to CI.
Symptom: every remote mismatches after a shared dependency upgrade.
Diagnosis: the server share scope resolved the old version because its container was cached from before the upgrade. Fix: key the container cache on the resolved URL so a new version is a cache miss.
Treating recovery rate as a metric, not an incident #
The most useful change most teams make here is not a fix at all — it is deciding that hydration-recovered is a number they watch. A federated page will never have a permanently zero rate, because remotes deploy constantly and there is always a small window in which a user’s page load spans a release. What matters is the shape of the curve.
A low, flat baseline that spikes on deploy days is expected and benign. A step change that appears with one release and stays is a real regression, and the component stack in the beacon usually names the remote in the first two frames. A rate that climbs through the day and resets on restart is per-request state leaking into module scope, because the leak only manifests once a process has served enough concurrent requests to interleave.
Reading the metric this way also settles the question of how hard to chase the last few mismatches. Server rendering is an optimisation; a mismatch costs you that optimisation for one region of one page view, not correctness. Spending a week eliminating a 0.2 per cent recovery rate is rarely worth it, while a 15 per cent rate means most users are paying for server rendering and receiving none of it — which is worth stopping everything for.