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.

Five causes of hydration mismatch, four of them federated The familiar React causes are the last row; the four above it only exist once a remote is built and resolved independently of its host. Cause Federation-specific? Where to look Server and client on different versions yes manifest resolution Share scope resolved differently yes strictVersion and load order Per-request state in module scope yes AsyncLocalStorage or context Remote has no server build yes client-only boundary Date, random id, locale drift no the usual React advice
Check the top row first — it is both the most common in federated applications and the fastest to rule out.

Prerequisites #

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 #

A hydration that succeeds because both sides agree The server stamps the version it rendered into the markup and serialises its resolution, so the client provably loads the same build. Server HTML Client React 1. render [email protected] 2. markup + data-build=4.2.1 3. load [email protected] from __REMOTES__ 4. hydrate 5. markup reused, no re-render
The data-build attribute costs nothing and turns an invisible failure into a one-line console check.

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 #

Removing ambient inputs from a rendered tree Anything a component reads from its environment rather than from its props will differ between a UTC server process and a user's browser. Ambient inputs Timezone read from the environment Locale detected per side Ids from a module-level counter Same code, different output Explicit inputs Timezone pinned in the formatter Locale passed in the init contract Ids from React's useId Same code, identical output
A module-level id counter is the subtlest of these: on the server it continues from the previous request rather than restarting.

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 #

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.