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' } };
}
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.
Verification #
- Block the entry URL. DevTools request blocking on
*remoteEntry.js; confirm three attempts, then an explained gap that keeps the slot’s height. - Return a 404 for the entry. Confirm exactly one attempt — the
missingclassification must short-circuit the retry loop. - 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.
- Simulate the outage ending. Unblock the URL, switch tabs and back; the slot must recover without a reload.
- 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.
Related #
- Micro-Frontend & Module Federation Isolation — the boundary, version and caching layers around this guard
- Isolating a Crashing Micro-Frontend Inside an Iframe Host — the escalation for remotes that fail at runtime, not load time
- Wrapping Lazy-Loaded Routes in Suspense and Error Boundaries — the same failure inside a single application