This page covers the strongest isolation tier from Micro-Frontend & Module Federation Isolation โ the one you escalate to when in-realm boundaries keep failing to contain a remote.
The exact problem #
One remote has taken the host down three times in a quarter. Once it defined a global that collided with the host router. Once its CSS reset leaked and destroyed the header. Once it shipped a React major the host could not share, producing invalid hook call errors deep inside unrelated code. Each incident was contained after it happened, by a boundary or a hotfix, and each one still reached users.
Module federation isolates modules. It does not isolate globals, styles, or framework instances. When a guest repeatedly breaks those, the honest answer is a different document.
Zero-to-working host #
// SandboxedRemote.tsx
type GuestMessage =
| { v: 1; type: 'ready'; height: number }
| { v: 1; type: 'beat' }
| { v: 1; type: 'resize'; height: number }
| { v: 1; type: 'error'; message: string }
| { v: 1; type: 'navigate'; path: string };
type HostMessage =
| { v: 1; type: 'init'; tokens: Record<string, string>; locale: string }
| { v: 1; type: 'data'; payload: unknown };
const BEAT_TIMEOUT_MS = 8000;
const MAX_HEIGHT = 2400;
export function SandboxedRemote({ src, origin, label, tokens }: Props) {
const frameRef = useRef<HTMLIFrameElement>(null);
const [state, setState] = useState<'loading' | 'live' | 'dead'>('loading');
const [height, setHeight] = useState(320);
const [nonce, setNonce] = useState(0); // bump to recreate the element
const lastBeat = useRef(Date.now());
useEffect(() => {
function onMessage(event: MessageEvent) {
// Two checks, both mandatory: the origin AND the exact frame.
if (event.origin !== origin) return;
if (event.source !== frameRef.current?.contentWindow) return;
const msg = event.data as GuestMessage;
if (!msg || msg.v !== 1 || typeof msg.type !== 'string') return;
switch (msg.type) {
case 'ready':
setState('live');
setHeight(clamp(msg.height));
frameRef.current?.contentWindow?.postMessage(
{ v: 1, type: 'init', tokens, locale: navigator.language } satisfies HostMessage,
origin, // never '*'
);
break;
case 'beat':
lastBeat.current = Date.now();
break;
case 'resize':
setHeight(clamp(msg.height));
break;
case 'error':
reportCrash({ scope: `sandbox:${label}`, message: msg.message });
break;
case 'navigate':
// The guest may request navigation; the HOST decides whether to obey.
if (isAllowedPath(msg.path)) history.pushState(null, '', msg.path);
break;
}
}
window.addEventListener('message', onMessage);
return () => window.removeEventListener('message', onMessage);
}, [origin, tokens, label, nonce]);
// Heartbeat watchdog โ a crashed guest cannot announce its own death.
useEffect(() => {
if (state !== 'live') return;
const id = setInterval(() => {
if (Date.now() - lastBeat.current > BEAT_TIMEOUT_MS) {
setState('dead');
reportCrash({ scope: `sandbox:${label}`, message: 'heartbeat lost' });
}
}, 2000);
return () => clearInterval(id);
}, [state, label]);
if (state === 'dead') {
return (
<GuestFallback
label={label}
height={height}
onRestart={() => { lastBeat.current = Date.now(); setState('loading'); setNonce((n) => n + 1); }}
/>
);
}
return (
<iframe
key={nonce} // a new element == a fresh document
ref={frameRef}
src={src}
title={label}
height={height}
loading="lazy"
referrerPolicy="strict-origin"
sandbox="allow-scripts allow-forms" // NOT allow-same-origin
style={{ width: '100%', border: 0, display: 'block' }}
/>
);
}
const clamp = (h: number) => Math.max(160, Math.min(Math.round(h), MAX_HEIGHT));
Step-by-step #
- Start from the most restrictive sandbox.
allow-scripts allow-formscovers most guests. Addingallow-same-originalongsideallow-scriptsfor a guest served from your origin removes the isolation entirely โ the guest can then reachparentdirectly. - Validate both origin and source. Origin alone is not enough: another frame from the same origin can post messages. Comparing
event.sourceto the specificcontentWindowpins it to your guest. - Version the contract.
v: 1on every message lets host and guest deploy independently without a coordinated release โ the same independence federation promised, now explicit. - Heartbeat, not error reporting, detects crashes. A guest that throws during render cannot reliably post an error. Missing beats is the only signal that survives a broken guest.
- Clamp the height. An unclamped resize message plus a guest whose layout depends on its height produces an oscillation that pins the CPU. Clamping breaks the loop; throttling in the guest keeps it cheap.
- Restart by recreating the element. Changing
srccan leave a broken document in the same browsing context. A newkeygives you a genuinely fresh start, the same reasoning as the remount key in Adding a Retry Button That Recovers Without a Full Reload.
| Concern | Federation | Sandboxed iframe |
|---|---|---|
| Crash containment | Boundary per slot; globals still shared | Complete: separate realm |
| CSS leakage | Possible in both directions | Impossible |
| Framework version | Must be compatible with the host | Independent |
| Communication | Direct props and imports | Typed messages only |
| Bundle cost | Shares the hostโs dependencies | Duplicates them |
| SEO and accessibility | Part of the host document | Separate document; needs a title and careful focus handling |
| Deep linking | Native routing | Requires a navigate message the host approves |
The guest side #
// guest/bootstrap.ts
const HOST_ORIGIN = 'https://app.example.com';
function post(msg: GuestMessage) {
parent.postMessage(msg, HOST_ORIGIN); // never '*'
}
window.addEventListener('error', (e) => post({ v: 1, type: 'error', message: e.message }));
window.addEventListener('unhandledrejection', (e) =>
post({ v: 1, type: 'error', message: String((e.reason as Error)?.message ?? e.reason) }),
);
const ro = new ResizeObserver(throttle(() => {
post({ v: 1, type: 'resize', height: document.documentElement.scrollHeight });
}, 150));
ro.observe(document.body);
setInterval(() => post({ v: 1, type: 'beat' }), 3000);
window.addEventListener('message', (event) => {
if (event.origin !== HOST_ORIGIN) return;
const msg = event.data as HostMessage;
if (msg?.v !== 1) return;
if (msg.type === 'init') applyTokens(msg.tokens);
});
post({ v: 1, type: 'ready', height: document.documentElement.scrollHeight });
Sending the hostโs design tokens through init is how theming survives the isolation: the guest applies them as CSS custom properties on its own root, so a dark-mode switch in the host propagates without either document touching the otherโs styles.
Verification #
- Throw inside the guest during render. Beats stop, the watchdog fires within eight seconds, the fallback appears, and the hostโs other content is untouched.
- Post a message from the wrong origin. Open a second origin in another tab and post to the host; the listener must ignore it, proving both checks.
- Send a hostile height. Post
{ type: 'resize', height: 1e9 }; the clamp must hold the frame at the maximum. - Restart after a crash. Confirm a brand-new document loads (check
performance.getEntriesByType('navigation')inside the guest) rather than a reused one. - Switch the host theme. Confirm the guest re-themes from the
inittokens, and that no host stylesheet rule can reach guest elements.
Frequently asked questions #
When is an iframe worth the cost over module federation?
When the guest is untrusted, repeatedly breaks the host through globals or CSS, or needs an incompatible framework version. The costs โ a separate document, duplicated framework code, a message contract instead of props โ make it an escalation, not a default.
How does the host know the guest crashed?
Through a heartbeat. A crashed guest may be unable to send anything, so missed beats are the only reliable signal.
Can the host style the iframeโs contents?
No, and that is the point. Pass design tokens in the handshake and let the guest apply them.
Related #
- Micro-Frontend & Module Federation Isolation โ when to escalate from a boundary to a sandbox
- Isolating Third-Party Widget Crashes with a Sandbox Boundary โ the same technique for vendor scripts
- Recovering When a Remote Module Fails to Load โ handling the load-time half of remote failures