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));
Host and guest across the iframe boundary Two large boxes side by side labelled host document and guest document, separated by a vertical line marked iframe boundary. Arrows between them are labelled init and data going right, and ready, beat, resize and error going left. A watchdog box inside the host counts missed beats and switches the slot to a fallback. iframe boundary: separate realm, separate globals, separate CSS host document message listener checks origin AND event.source heartbeat watchdog no beat for 8 s โ†’ fallback + restart height clamped to 160โ€“2400 px guest document own framework version own globals, own stylesheet beat every 3 s resize on ResizeObserver, throttled host โ†’ guest: init, data ยท guest โ†’ host: ready, beat, resize, error

Step-by-step #

  1. Start from the most restrictive sandbox. allow-scripts allow-forms covers most guests. Adding allow-same-origin alongside allow-scripts for a guest served from your origin removes the isolation entirely โ€” the guest can then reach parent directly.
  2. Validate both origin and source. Origin alone is not enough: another frame from the same origin can post messages. Comparing event.source to the specific contentWindow pins it to your guest.
  3. Version the contract. v: 1 on every message lets host and guest deploy independently without a coordinated release โ€” the same independence federation promised, now explicit.
  4. 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.
  5. 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.
  6. Restart by recreating the element. Changing src can leave a broken document in the same browsing context. A new key gives 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.

Heartbeat loss, fallback and restart A timeline with regular beat marks every three seconds. The beats stop at a crash marker. Eight seconds later a watchdog marker fires and the host switches to a fallback. A restart marker follows, after which beats resume at the same interval. guest crashes watchdog fires (8 s) no beats โ€” host still shows the last rendered frame user restarts fresh document, beats resume the slot keeps its height throughout, so nothing on the host page moves Sandbox combinations and what they permit Three rows of sandbox attribute sets with their effect: scripts and forms only gives full isolation, adding allow-popups is still isolated, and adding allow-same-origin on a same-origin guest lets the guest reach the host directly, which defeats the purpose. sandbox attributes effect on isolation allow-scripts allow-forms isolated โ€” the recommended default + allow-popups allow-downloads still isolated โ€” grant only if the guest needs them + allow-same-origin (same origin) isolation gone โ€” the guest can touch parent directly

Verification #

  1. 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.
  2. 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.
  3. Send a hostile height. Post { type: 'resize', height: 1e9 }; the clamp must hold the frame at the maximum.
  4. Restart after a crash. Confirm a brand-new document loads (check performance.getEntriesByType('navigation') inside the guest) rather than a reused one.
  5. Switch the host theme. Confirm the guest re-themes from the init tokens, 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.