This guide applies the framework recovery patterns in Framework-Specific Crash Recovery & Error Handlers to an architecture where the failing code was written, built, and deployed by a different team than the one operating the page.
The problem: independent deploys, shared runtime #
Module Federation gives every team its own deploy pipeline while the browser gives them all one JavaScript realm. That combination produces failure modes no single-repo application has:
- The host ships on Tuesday, a remote ships on Thursday, and the pair that runs in production on Friday was never built or tested together.
- A remote’s
remoteEntry.jsis fetched at runtime from a different origin. If that CDN has an incident, the host renders a hole where a paid feature used to be. - Shared singletons — React, the router, a design-system context — are resolved at load time by whichever remote arrives first. A version mismatch surfaces as “Invalid hook call” inside code that has not changed in months.
- A crash in a remote unwinds through the host’s fiber tree. Without a boundary at the mount point, one team’s bad release blanks another team’s page.
The user-visible consequence is almost always the same: a page that is 90% healthy renders as a blank screen or a full-page error, because the failure of one slot was allowed to propagate to the shell. Everything below is aimed at converting that into a missing slot with an explanation.
Prerequisites #
Core implementation: load guard, then render boundary #
The single most common mistake is treating “the remote failed to load” and “the remote crashed while rendering” as one problem. They need different handling: a load failure is often transient and retryable; a render crash is usually deterministic and must be contained and reported.
// loadRemote.ts
export interface RemoteSpec {
name: string; // 'checkout'
url: string; // https://cdn.example.com/checkout/remoteEntry.js
module: string; // './CheckoutPanel'
buildId?: string;
}
interface LoadResult<T> {
status: 'ok' | 'unavailable';
Component: T | null;
reason?: string;
}
const scriptCache = new Map<string, Promise<void>>();
function loadScript(url: string): Promise<void> {
const cached = scriptCache.get(url);
if (cached) return cached;
const p = new Promise<void>((resolve, reject) => {
const el = document.createElement('script');
el.src = url;
el.type = 'text/javascript';
el.async = true;
el.crossOrigin = 'anonymous';
el.onload = () => resolve();
el.onerror = () => reject(new Error(`remoteEntry unreachable: ${url}`));
document.head.appendChild(el);
});
scriptCache.set(url, p);
// A failed load must not poison the cache — the next attempt should retry.
p.catch(() => scriptCache.delete(url));
return p;
}
/**
* Never rejects. A remote that cannot be loaded resolves to a null component
* so the host renders an explained gap instead of unwinding to a boundary.
*/
export async function loadRemote<T = unknown>(
spec: RemoteSpec,
attempts = 3,
): Promise<LoadResult<T>> {
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
await loadScript(spec.url);
const container = (window as never as Record<string, Container>)[spec.name];
if (!container) throw new Error(`container ${spec.name} not exposed`);
// __webpack_init_sharing__ must run before get(): it primes the shared
// scope so the remote resolves React from the host instead of bundling one.
await __webpack_init_sharing__('default');
await container.init(__webpack_share_scopes__.default);
const factory = await container.get(spec.module);
return { status: 'ok', Component: factory() as T };
} catch (err) {
const last = attempt === attempts;
if (last) {
return {
status: 'unavailable',
Component: null,
reason: err instanceof Error ? err.message : 'unknown load failure',
};
}
// Exponential backoff with jitter — a CDN blip recovers, a 404 will not.
await new Promise((r) => setTimeout(r, 2 ** attempt * 150 + Math.random() * 100));
}
}
return { status: 'unavailable', Component: null, reason: 'exhausted' };
}
The host then mounts the result inside a boundary scoped to that remote, so a render crash is attributed correctly and contained to the slot:
// RemoteSlot.tsx
export function RemoteSlot({ spec, label }: { spec: RemoteSpec; label: string }) {
const [state, setState] = useState<LoadResult<React.ComponentType> | null>(null);
useEffect(() => {
let live = true;
void loadRemote<React.ComponentType>(spec).then((r) => live && setState(r));
return () => { live = false; };
}, [spec.url, spec.module]);
if (!state) return <SlotSkeleton label={label} />;
if (state.status === 'unavailable' || !state.Component) {
return <SlotUnavailable label={label} reason={state.reason} onRetry={() => setState(null)} />;
}
const Remote = state.Component;
return (
<AppBoundary
scope={`remote:${spec.name}@${spec.buildId ?? 'unknown'}`}
fallback={<SlotCrashed label={label} />}
>
<Remote />
</AppBoundary>
);
}
Architecture note: why the load guard cannot be a boundary #
An error boundary only sees errors thrown during render, commit, or lifecycle of components beneath it. A dynamic import() that rejects produces a rejected promise, not a render throw — the same distinction analysed in Error Propagation Strategies. With React.lazy plus Suspense, React does bridge that rejection into the nearest boundary, which is why so many teams believe a boundary is sufficient. It is, for the first failure. What it does not give you is retry with backoff, a distinction between 404 and network error, or the ability to fall back to a cached copy of the entry — all of which belong in the loading layer, before React is involved.
The practical rule: the load guard decides whether there is a component at all; the boundary decides what happens when that component misbehaves. Conflating them means every CDN blip is reported as an application crash and every application crash is retried three times.
Failure modes and mitigations #
| Failure | Where it surfaces | Mitigation |
|---|---|---|
remoteEntry.js 404 after a remote’s deploy rollback |
Blank slot, console script error | Load guard resolves to unavailable; cached entry fallback serves the previous build |
| CDN timeout under load | Slot stuck in skeleton forever | Race the load against a timeout and treat a timeout as unavailable |
| Remote throws during render | Host route blanks without a per-slot boundary | Boundary scoped remote:<name>@<buildId> at every mount point |
| Two remotes want incompatible React majors | “Invalid hook call” deep inside a remote | Strict singleton plus a CI check that all remotes resolve the same major |
| Remote mutates a global the host relies on | Unrelated feature breaks minutes later | Sandbox the remote in an iframe with a message contract |
| Remote’s CSS leaks into the shell | Layout shifts across the whole page | Scope styles at build time; verify with a visual check on the host |
The last two rows share a root cause: federation isolates modules, not side effects. Anything a remote does to window, to the DOM outside its mount node, or to global CSS is a host concern. When a team repeatedly trips those wires, escalate that remote to an iframe rather than negotiating conventions release after release.
Advanced: cached entry fallback for a CDN outage #
Federation’s weakest link in production is the network fetch of the remote entry. A service worker turns that into a soft failure by keeping the last known good entry and serving it when the network path fails.
// sw.js (excerpt) — stale-if-error for federated entries
const ENTRY_CACHE = 'remote-entries-v1';
const ENTRY_PATTERN = /\/remoteEntry\.js$/;
self.addEventListener('fetch', (event) => {
const { request } = event;
if (request.method !== 'GET' || !ENTRY_PATTERN.test(new URL(request.url).pathname)) return;
event.respondWith((async () => {
const cache = await caches.open(ENTRY_CACHE);
try {
// Network first: a remote's new deploy must be picked up promptly.
const fresh = await fetch(request, { cache: 'no-store' });
if (!fresh.ok) throw new Error(`entry responded ${fresh.status}`);
await cache.put(request, fresh.clone());
return fresh;
} catch (err) {
const stale = await cache.match(request);
if (stale) {
// Signal staleness so the host can badge the slot as degraded.
const headers = new Headers(stale.headers);
headers.set('X-Remote-Entry', 'stale');
return new Response(await stale.blob(), { status: 200, headers });
}
return new Response('/* remote unavailable */', {
status: 503,
headers: { 'Content-Type': 'application/javascript' },
});
}
})());
});
This is the federation-specific application of the pattern in App Shell Precache & Offline Fallback Routing. Two cautions: a stale entry may reference chunk files that the CDN has already deleted, so cache the remote’s chunks alongside its entry or accept that stale mode can still fail at chunk-load time; and never serve a stale entry indefinitely — stamp it with a timestamp and stop honouring it after a bounded window so a genuinely retired remote does eventually disappear.
Validating version compatibility in CI #
Most unreproducible remote failures come from a version pair that only ever exists in production. A cheap pipeline step removes the category: fetch every registered remote’s manifest, read the shared-dependency ranges it was built against, and fail the build when they cannot all resolve to one singleton version.
// scripts/check-federation-compat.ts
import { major } from 'semver';
interface RemoteManifest { name: string; buildId: string; shared: Record<string, string>; }
const SINGLETONS = ['react', 'react-dom', 'react-router-dom'];
const manifests: RemoteManifest[] = await Promise.all(
REMOTES.map(async (r) => (await fetch(`${r.origin}/federation-manifest.json`)).json()),
);
let failed = false;
for (const dep of SINGLETONS) {
const byMajor = new Map<number, string[]>();
for (const m of manifests) {
const range = m.shared[dep];
if (!range) continue;
const maj = major(range.replace(/^[^\d]*/, ''));
byMajor.set(maj, [...(byMajor.get(maj) ?? []), `${m.name}@${m.buildId}`]);
}
if (byMajor.size > 1) {
failed = true;
console.error(`✗ ${dep} resolves to ${byMajor.size} majors across remotes:`);
for (const [maj, owners] of byMajor) console.error(` v${maj}: ${owners.join(', ')}`);
}
}
process.exit(failed ? 1 : 0);
Run it on every host deploy and on a schedule, because the pair that breaks is created by whichever team deploys last — the host’s pipeline may have been green for a week by then.
Deep dives in this guide #
- Recovering When a Remote Module Fails to Load in Module Federation walks the retry, timeout and placeholder path end to end.
- Isolating a Crashing Micro-Frontend Inside an Iframe Host covers the sandbox escalation, the message contract, and resizing without layout thrash.
Contracts that keep independence real #
Federation is usually adopted for organisational reasons: separate teams, separate release trains, separate ownership. The technical isolation only preserves that independence if a small number of contracts are written down and enforced, because every one of them is easy to violate accidentally from inside a remote.
The first contract is the props boundary. A remote receives data and callbacks, never a store instance or a router object — passing either turns a version bump in the host into a coordinated release across every team. The second is the DOM boundary: a remote owns its mount node and nothing outside it, which makes “the widget broke our header” a bug with an obvious owner rather than an investigation. The third is the global boundary: no remote defines or mutates a global, and a lint rule catching window.X = in remote source is cheaper than the incident it prevents.
The fourth contract is about failure itself. Every remote must render something meaningful when its data is unavailable, because the host cannot know what a sensible degraded state looks like for a feature it does not own. A remote that throws rather than degrading has effectively delegated its own error handling to a team that has never seen its designs.
Frequently asked questions #
Why does a remote crash take down my entire host application?
Because federated remotes execute in the host’s realm and render inside the host’s component tree. A throw during a remote’s render unwinds to the nearest host boundary — often the root. Mounting each remote inside its own scoped boundary confines the failure to that slot.
What happens when two remotes need different versions of React?
With React as a strict singleton the first loaded version wins, and the second remote runs against an API it was not built for — usually surfacing as invalid hook call errors. Either enforce one major across all remotes in CI, or drop the singleton for that remote and accept two React copies with the memory and context-sharing costs.
Should a failed remote retry automatically?
Retry the load, not the render. Network failures fetching an entry are often transient and worth two or three backed-off attempts; a render crash usually reproduces deterministically, so offer an explicit retry control instead and report the crash with the remote’s name and build id.
How do I debug a remote failure I cannot reproduce locally?
Attach the remote name, entry URL and build id to every crash payload, and log resolved shared-dependency versions at load time. Most unreproducible remote failures are a version combination that only exists in production because host and remote deployed at different times.
Related #
- Framework-Specific Crash Recovery & Error Handlers — the framework-level handlers this architecture composes
- Component Isolation Techniques — sandboxing untrusted code inside a single application
- React Error Boundary Implementation — the boundary component each remote slot mounts
- App Shell Precache & Offline Fallback Routing — the caching layer the stale-entry fallback builds on