The question
Module Federation promises that separate frontend builds can ship independently and still compose one product. The interesting part is not the happy-path diagram. It is what the user sees when one remote is slow, unavailable, or built against an incompatible shared dependency.
I built the lab to make those failures reproducible. It contains a shell, catalog, orders, and a shared design-system remote, each with its own Webpack build and development server.
shell/ host :3000 routing, composition, theme
catalog/ remote :3001 product list
orders/ remote :3002 order table
design-system/ remote :3003 tokens, theme, button
contracts/ tests shared boundary checks
The runtime boundary
The shell imports modules that do not exist on its filesystem:
const ProductList = lazy(() => import("catalog/ProductList"));
const OrderTable = lazy(() => import("orders/OrderTable"));
Webpack resolves them from remote containers at runtime. That buys independent builds, but also moves failures that a monolith would catch during compilation into the browser.
Each remote therefore has two explicit states:
<RemoteErrorBoundary name={name} title={title}>
<Suspense fallback={<LoadingPanel title={title} />}>
{children}
</Suspense>
</RemoteErrorBoundary>
Suspense covers a slow remote. The error boundary covers a rejected load. If
the orders server is stopped and the shell is hard-reloaded, the catalog stays
usable while the orders panel explains that it is unavailable.
The difficult decision: strict or lenient versions
React and React DOM are shared as singletons. Two React instances on one page can break hooks, so every build must resolve the same instance.
The lab can rebuild only the orders remote with an impossible React requirement:
{
singleton: true,
requiredVersion: "^99.0.0",
strictVersion: true,
}
With strict versions, the incompatible panel fails immediately and its boundary takes over. With lenient versions, Webpack warns and renders with the available React version, deferring the failure until code reaches an incompatible API.
Neither policy is free. I used strict versions because the shell has a tested degraded state. Without that recovery path, strictness would turn a dependency mismatch into a wider outage.
Cache policy is part of correctness
A stopped remote initially appeared healthy because the browser still had its container entry. That exposed a deployment rule the architecture diagram did not show:
remoteEntry.jsis served withno-cache, so the shell discovers a new deployment or outage.- hashed chunks are immutable and cached long-term.
Caching the entry file long-term can leave the shell pointing at an obsolete chunk graph. Disabling caching for every chunk throws away safe reuse. The two asset types need different policies.
Shared UI can widen the blast radius
Catalog and orders both consume a theme context from the design-system remote. That context works only when every consumer resolves the same container. If one app points to another copy—even byte-identical code—it gets another context object and silently falls back to the default theme.
The lab tests the container name and URL as a contract:
test("every app resolves the same design-system container", () => {
const remote = "design_system@http://localhost:3003/remoteEntry.js";
for (const app of ["shell", "catalog", "orders"]) {
assert.ok(read(`${app}/webpack.config.js`).includes(remote));
}
});
Stopping that shared remote degrades both product panels. The design system creates consistency, but it also sits on both panels' critical path. The shell therefore owns a local provider fallback rather than expecting one remote-level error boundary to protect the whole tree.
Verification and delivery
The contract suite checks remote names, exposed modules, shared versions, container URLs, and cache headers. Browser tests run the composed page, stop a remote, reload, and verify that one failed panel does not remove its siblings. The same flow covers incompatible-version and missing-design-system states.
Each app has an independent production build. CI builds all four, runs the contract checks, and then runs the failure-path browser suite against the built assets. The lab is useful only if a broken deploy is exercised before the configuration is described as resilient.
<details> <summary>Detailed notes</summary>Three configuration details that matter
The application starts behind an asynchronous bootstrap import. Federation must
negotiate shared dependencies before React evaluates; a synchronous entry can
fail with Shared module is not available for eager consumption.
publicPath: "auto" makes a remote load its own chunks from its own origin.
Without it, a chunk from the catalog can be requested from the shell and return
a 404.
Every place that consumes a lazy remote owns an error boundary. Suspense handles a pending promise, not a rejected one; relying on Suspense alone allowed a failed shared button import to unmount the page.
A CSS failure with no stack trace
The theme provider writes custom properties on its wrapper. Aliases originally
lived on :root, above that wrapper, so they could not read descendant values.
Text inherited the theme while borders kept their fallback colors. Moving the
aliases into the provider subtree fixed the half-themed page.
What the lab does not solve
It does not implement shared authentication, cross-remote application state, server rendering, or automatic rollback to a known-good remote. Those require separate contracts and deployment controls. The point of this build is narrower: make runtime composition failures visible, contained, and testable.
</details>