This page details the load-guard layer introduced in Micro-Frontend & Module Federation Isolation, where the boundary and iframe layers are covered.

The exact problem #

A host application composes four remotes. One team’s CDN has a bad five minutes. The host’s recommendations slot renders nothing — no skeleton, no message, no console error a user could report. Support tickets say “the page looks wrong on mobile”, and nobody can reproduce it because by the time anyone looks, the CDN is healthy again.

The reason nothing surfaced: a <script> that 404s fires an error event on the element. It does not throw into your call stack, so no boundary catches it, and window.recommendations simply never exists. The host asked for a component, got undefined, and rendered it.

Zero-to-working guard #

// remoteLoader.ts
export type LoadFailure =
  | { kind: 'missing'; status: number }     // 404/410 — will not recover on retry
  | { kind: 'network' }                     // offline, DNS, TLS, aborted connection
  | { kind: 'timeout'; ms: number }
  | { kind: 'init'; message: string };      // entry loaded but container.init threw

export type LoadOutcome<T> =
  | { ok: true; Component: T; buildId?: string }
  | { ok: false; failure: LoadFailure };

const TIMEOUT_MS = 9000;
const inflight = new Map<string, Promise<void>>();

function loadEntry(url: string): Promise<void> {
  const existing = inflight.get(url);
  if (existing) return existing;

  const p = new Promise<void>((resolve, reject) => {
    const el = document.createElement('script');
    el.src = url;
    el.async = true;
    el.crossOrigin = 'anonymous';
    el.onload = () => resolve();
    // No status code is exposed here — a probe below recovers it.
    el.onerror = () => reject(new Error('entry script failed'));
    document.head.appendChild(el);
  });

  inflight.set(url, p);
  p.catch(() => inflight.delete(url));      // never cache a failure permanently
  return p;
}

/** A script error gives no status; a HEAD probe distinguishes 404 from offline. */
async function classify(url: string): Promise<LoadFailure> {
  if (!navigator.onLine) return { kind: 'network' };
  try {
    const res = await fetch(url, { method: 'HEAD', cache: 'no-store' });
    if (res.status === 404 || res.status === 410) return { kind: 'missing', status: res.status };
    return { kind: 'network' };
  } catch {
    return { kind: 'network' };
  }
}

export async function loadRemote<T>(spec: RemoteSpec, attempts = 3): Promise<LoadOutcome<T>> {
  for (let attempt = 1; attempt <= attempts; attempt += 1) {
    const timeout = new Promise<'timeout'>((r) => setTimeout(() => r('timeout'), TIMEOUT_MS));

    try {
      const raced = await Promise.race([loadEntry(spec.url).then(() => 'loaded' as const), timeout]);
      if (raced === 'timeout') {
        if (attempt === attempts) return { ok: false, failure: { kind: 'timeout', ms: TIMEOUT_MS } };
        continue;
      }

      const container = (window as never as Record<string, Container>)[spec.name];
      if (!container) throw new Error(`container "${spec.name}" not exposed by ${spec.url}`);

      await __webpack_init_sharing__('default');
      await container.init(__webpack_share_scopes__.default);
      const factory = await container.get(spec.module);

      return { ok: true, Component: factory() as T, buildId: container.buildId };
    } catch (err) {
      const failure = await classify(spec.url);
      // A missing file will still be missing in 400 ms. Stop.
      if (failure.kind === 'missing' || attempt === attempts) {
        return { ok: false, failure: err instanceof Error && /container|init/.test(err.message)
          ? { kind: 'init', message: err.message }
          : failure };
      }
      await new Promise((r) => setTimeout(r, 300 * 2 ** (attempt - 1) + Math.random() * 250));
    }
  }
  return { ok: false, failure: { kind: 'network' } };
}
Load, classify, retry or degrade A flow starting at load entry with timeout. Success proceeds to container init and then to the rendered remote. Failure proceeds to a classify step producing four kinds. Missing goes straight to explained gap. Network and timeout go to jittered retry, which loops back to load entry up to three times. Init failure goes to explained gap with a version note. load entry (9 s timeout) loaded failed container.init + get() remote renders classify via HEAD probe missing · network · timeout · init network / timeout jittered retry ×3 explained gap missing / init: no retry a missing file is not a transient failure — retrying it only delays the honest message

The explained gap #

// RemoteGap.tsx
const COPY: Record<LoadFailure['kind'], { title: string; body: string; retry: boolean }> = {
  missing:  { title: 'Not available right now', body: 'This section has been moved or retired.', retry: false },
  network:  { title: 'Could not load this section', body: 'Your connection dropped while loading it.', retry: true },
  timeout:  { title: 'This section is slow to load', body: 'It did not respond in time.', retry: true },
  init:     { title: 'This section could not start', body: 'A compatibility problem stopped it from loading.', retry: false },
};

export function RemoteGap({ label, failure, height, onRetry }: Props) {
  const copy = COPY[failure.kind];
  return (
    <section role="status" style={{ minHeight: height }} className="remote-gap" aria-label={`${label}: ${copy.title}`}>
      <h3>{label}</h3>
      <p>{copy.body}</p>
      {copy.retry && <button type="button" onClick={onRetry}>Try loading again</button>}
    </section>
  );
}

The slot keeps the height it was given by the layout, so nothing below it moves — the same discipline as Implementing Fallback Rendering Without Layout Shift. Note role="status" rather than role="alert": a missing recommendations rail is information, not an emergency, and an assertive announcement would interrupt whatever the user was reading.

Failure kind Retry automatically Show a retry control Report severity
missing (404/410) No No Warning — usually a deploy/rollback mismatch
network Yes, up to 3 Yes Info unless it persists
timeout Yes, up to 3 Yes Warning — track p95 entry load time
init (shared version clash) No No Error — blocks that remote for everyone

Retrying when the user comes back #

Most CDN incidents outlast a user’s patience but not their session. Retrying on visibility change and reconnect turns a dead slot into a self-healing one.

// useRemoteWithRecovery.ts
export function useRemoteWithRecovery<T>(spec: RemoteSpec) {
  const [outcome, setOutcome] = useState<LoadOutcome<T> | null>(null);
  const attempt = useCallback(() => { void loadRemote<T>(spec).then(setOutcome); }, [spec.url, spec.module]);

  useEffect(attempt, [attempt]);

  useEffect(() => {
    if (outcome?.ok !== false) return;
    if (outcome.failure.kind === 'missing' || outcome.failure.kind === 'init') return;  // hopeless

    const onVisible = () => { if (document.visibilityState === 'visible') attempt(); };
    document.addEventListener('visibilitychange', onVisible);
    window.addEventListener('online', attempt);
    return () => {
      document.removeEventListener('visibilitychange', onVisible);
      window.removeEventListener('online', attempt);
    };
  }, [outcome, attempt]);

  return { outcome, retry: attempt };
}

Gating on the failure kind is what stops this becoming a polling loop against a URL that will never exist. For genuine connectivity changes, the reachability monitor in Connectivity Detection & Offline UX Patterns is a better trigger than the raw online event.

Self-healing slot across a CDN outage A timeline marked with outage start, three failed load attempts clustered near the start, an explained gap period, the outage ending, a tab-visible event, and a successful retry that renders the remote without a reload. CDN outage · explained gap on screen 3 attempts, jittered outage ends tab becomes visible retry succeeds — remote renders no page reload, no user action, and no polling while the tab was hidden The load races a timeout, and the loser is ignored Two parallel tracks from a single start: the entry load track and the timeout track. Whichever completes first decides the outcome; a note explains that without the timeout a stalled request leaves a skeleton on screen indefinitely. attempt starts entry script load then container.init() 9 s timeout resolves 'timeout' first to settle wins the loser's result is discarded

Verification #

  1. Block the entry URL. DevTools request blocking on *remoteEntry.js; confirm three attempts, then an explained gap that keeps the slot’s height.
  2. Return a 404 for the entry. Confirm exactly one attempt — the missing classification must short-circuit the retry loop.
  3. Throttle to a stall. Use a network condition with a huge latency; confirm the timeout fires at nine seconds rather than hanging in a skeleton.
  4. Simulate the outage ending. Unblock the URL, switch tabs and back; the slot must recover without a reload.
  5. Check the report. One report per slot with scope: remote:<name> and the failure kind, so a CDN incident is one issue rather than one per user — see Error Telemetry & Crash Reporting.

Frequently asked questions #

Why does a failed remote leave an empty box with no error?

Because a 404 on a script tag fires an element error event, not an exception. Nothing throws, no boundary fires, and the container global never appears — so the host renders nothing.

How long should the load timeout be?

Eight to ten seconds for the entry, with the whole retry sequence capped near twenty. Longer and users have already decided the page is broken.

Should the host cache a failed remote so it stops trying?

Cache the failure for the current view, not the session: enough to avoid retry storms, cleared on visibility change or reconnect so a recovered CDN is picked up.