Skip to content

Remote-load fallbacks

An error boundary and a fallback UI around every remote the shell mounts, so that when a remote’s remoteEntry.js fails to load — the CDN is down, a deploy went bad, the network flaked — that one slice shows a small “couldn’t load” panel and the rest of the storefront keeps working.

This is the first lesson of the module because it names the price of runtime composition. In a single-page app, the whole bundle either loads or it doesn’t. In Mosaic, each remote loads at runtime from its own URL, which means each remote can fail independently — and if the shell doesn’t handle that, one broken remote takes the whole page down with it.

React.lazy(() => import('catalog/Catalog')) returns a promise. When the import resolves, <Suspense> swaps the fallback for the real component. But <Suspense> only handles the pending state — it has no answer for the rejected state. If remoteEntry.js 404s or the network drops, that promise rejects, the rejection propagates up as a render error, and without a boundary it unmounts everything above it. The shell — nav, session, cart badge, the other remotes — goes blank.

An error boundary catches that rejection at a chosen point in the tree and renders a fallback instead of letting it bubble. Put a boundary immediately around each remote and the blast radius of a failed load is exactly one slice. This is the same isolation principle a service mesh gives microservices: a dependency being down degrades one feature, it doesn’t cascade.

One boundary per remote vs. one boundary around the whole app

  • Pros (per remote): A failed load degrades exactly one slice; the nav, session, and other remotes stay interactive. The fallback can be specific (“Catalog is unavailable”) and can offer a retry that re-imports only that remote.
  • Cons (per remote): More boundaries to write and place, and you have to design a sensible degraded state for each slice rather than a single generic error page.

Error boundary vs. a try/catch around the import

  • Pros (boundary): Catches both load-time rejections and render-time errors thrown inside the remote after it mounts, in one mechanism. It’s the idiomatic React seam for “this subtree failed.”
  • Cons (boundary): Must be a class component (or a wrapper like react-error-boundary) — there’s still no hook form — and it only catches errors during render/lifecycle, not inside event handlers, so remote click handlers still need their own guarding.

A small error boundary with a retry. It resets the failure state on demand, so a stuck fallback doesn’t require the user to know they can reload the page — though as the Verify section below shows, a dead JS import specifically needs that reload to actually recover.

import { Component, type ReactNode } from 'react';
type Props = {
/** Shown in the fallback, e.g. "Catalog". */
name: string;
children: ReactNode;
};
type State = { error: Error | null };
export class RemoteBoundary extends Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error) {
// In production, forward this to your telemetry so a failing
// remote is visible even though the shell survived.
console.error(`[shell] remote "${this.props.name}" failed`, error);
}
reset = () => this.setState({ error: null });
render() {
if (this.state.error) {
return (
<div className="remote-fallback" role="alert">
<p>{this.props.name} is unavailable right now.</p>
<button onClick={this.reset}>Try again</button>
</div>
);
}
return this.props.children;
}
}

Since Web Components Interop, the catalog mounts through the same custom-element boundary as the cart — App.tsx imports catalog/register for its side effect and renders <catalog-app>, not React.lazy(() => import('catalog/Catalog')) anymore. So its failure mode is the one below: import('catalog/register') can reject before <catalog-app> is ever defined. Guard the registration, not a React component.

import { Component, type ReactNode } from 'react';
import { RemoteBoundary } from './RemoteBoundary';
// Imperatively load + register the custom element, surfacing failure to React.
class CatalogLoader extends Component<{ children: ReactNode }, { ready: boolean; error: Error | null }> {
state = { ready: false, error: null as Error | null };
async componentDidMount() {
try {
await import('catalog/register'); // defines <catalog-app>
this.setState({ ready: true });
} catch (error) {
// Re-throw on the next render so RemoteBoundary catches it.
this.setState(() => {
throw error as Error;
});
}
}
render() {
return this.state.ready ? this.props.children : <div className="remote-loading">Loading catalog…</div>;
}
}
export function MountCatalog() {
return (
<RemoteBoundary name="Catalog">
<CatalogLoader>
{/* Custom element — the React types accept it as an intrinsic tag. */}
<catalog-app></catalog-app>
</CatalogLoader>
</RemoteBoundary>
);
}

Because a dynamic import() rejection lands in a promise, not in React’s render, we re-throw it from a state updater so RemoteBoundary can see it — a plain .then(() => setReady(true)) with no .catch (like CatalogRoute.tsx used before this module) would leave the failure stuck in “Loading…” forever, since the rejection never reaches React’s render cycle at all.

The cart remote mounts the exact same way — a custom element behind a registrar import — so CartLoader is CatalogLoader with the names swapped. Two small, explicit copies here beat one generic loader parameterized by module id: Module Federation’s plugin recognizes federated import() calls by their literal string specifier, and a dynamic specifier would risk breaking that.

import { Component, type ReactNode } from 'react';
import { RemoteBoundary } from './RemoteBoundary';
// Imperatively load + register the custom element, surfacing failure to React.
class CartLoader extends Component<{ children: ReactNode }, { ready: boolean; error: Error | null }> {
state = { ready: false, error: null as Error | null };
async componentDidMount() {
try {
await import('cart/register'); // defines <cart-app>
this.setState({ ready: true });
} catch (error) {
// Re-throw on the next render so RemoteBoundary catches it.
this.setState(() => {
throw error as Error;
});
}
}
render() {
return this.state.ready ? this.props.children : <div className="remote-loading">Loading cart…</div>;
}
}
export function MountCart() {
return (
<RemoteBoundary name="Cart">
<CartLoader>
{/* Custom element — the React types accept it as an intrinsic tag. */}
<cart-app></cart-app>
</CartLoader>
</RemoteBoundary>
);
}

Run the shell against remotes, then take one remote offline and confirm the shell survives.

Terminal window
# Terminal 1 — shell only (do NOT start the catalog remote)
pnpm --filter shell dev
# ➜ Local: http://localhost:5000/

Open http://localhost:5000/. Because nothing is serving http://localhost:5001/remoteEntry.js, expect:

  • The Catalog area shows “Catalog is unavailable right now.” with a Try again button.
  • The top-nav, session, and cart badge are still rendered and interactive — the page did not go blank.
  • The console shows a single [shell] remote "Catalog" failed line, not an unhandled React tree crash.

Now start the remote back up:

Terminal window
# Terminal 2
pnpm --filter catalog dev
# ➜ Local: http://localhost:5001/

Click Try again in the Catalog panel — the boundary resets and the loader re-runs import('catalog/register'), but the browser’s module loader has already cached that specific import’s rejection, so this first click fails again with the same message. That’s expected, not a bug in RemoteBoundary: a rejected dynamic import is cached forever by the JS engine, the same way a 404’d script tag never magically retries itself. Reload the page — a fresh page load re-evaluates the import from scratch against the now-running remote — and the catalog grid mounts. In production this is exactly why Module Federation runtime plugins like a retry plugin exist: they retry the underlying fetch, not the cached import(), so a transient failure can recover without a reload. Finish with a production build to confirm the boundary compiles cleanly:

Terminal window
pnpm --filter shell build
# ✓ built in … — no type errors from RemoteBoundary / MountCart

Check your understanding:

  1. CatalogLoader and CartLoader both catch the load failure in a try/catch around an await import(...). Why isn’t the catch block alone enough to show the fallback — why the this.setState(() => { throw error; }) on top of it?
  2. CatalogRoute.tsx (from Module 5) used a plain .then(() => setReady(true)) with no .catch. What would the user see if a remote failed to load while mounted that way, and why?
  3. Both remotes now load via import('.../register'), not React.lazy. Why does that failure need to be re-thrown from a state updater to reach the boundary?
  4. What is the “blast radius” difference between one boundary per remote and a single boundary around the whole shell?

You wrapped each remote in an error boundary so a failed remoteEntry.js degrades exactly one slice instead of blanking the shell — the isolation that makes runtime composition safe. Both remotes mount through the same custom-element loader shape, including the load-time rejection seam that needs an explicit re-throw to reach RemoteBoundary.

Next, we make composition fast as well as safe: Lazy loading & dedupe →.