Module Federation micro-frontends
- Role
- Lead engineer — architecture owner
- Stack
- Lit 3 web components · Webpack 5 Module Federation · Native ESM output · Import maps
- Scope
- One shell host plus two product remotes, each deployed on its own cadence · two generated federation containers publishing ~280 shared modules and ~185 design-system elements · one import map resolving the framework runtime for all three builds
A consumer bank's public site — the product and marketing surface, not the authenticated application — served two distinct audiences from a single web-component SPA: retail and business. One repo, one build, one deploy queue. Every release coupled both product lines: a retail change waited on business QA, the bundle grew monotonically, and a design-system upgrade was a whole-app event. The target was three independently deployed applications that still behave as one SPA on one origin, sharing exactly one instance of the framework runtime.
┌──────────────────── shell (host) ────────────────────┐
│ chrome: header · footer · router · search │
│ consumes: retail/entry business/entry │
│ exposes: common (common-entry.mjs) │
│ ui-elements (ui-entry.mjs) │
└───────────────┬───────────────────────┬──────────────┘
│ import() │ import()
┌────────┴─────────┐ ┌─────────┴──────────┐
│ retail (remote) │ │ business (remote) │
│ retail-entry.mjs │ │ business-entry.mjs │
└────────┬─────────┘ └─────────┬──────────┘
└────── import map ─────┘
▼
framework runtime + ~106 design-system packages
resolved from CDN — one copy, shared by all threeDecisions worth defending
Module Federation over iframes or a runtime orchestrator. The apps share a design system built on custom elements, and customElements is a single per-window registry — a second copy of the framework doesn't get its own namespace, it collides. A second define() of an already-registered tag throws NotSupportedError, and where tags don't collide you get two ReactiveElement base classes instead: instanceof checks across the boundary fail and updates schedule on two independent queues. Any composition model that duplicates the framework runtime is therefore out. Federation with library: { type: 'module' } and experiments.outputModule emits real ES modules, so remotes load through the browser's own import() — no custom loader, no global namespace, and the shell's route-level code splitting keeps working unchanged.
The shell is both host and remote. It consumes the two product remotes for page components and simultaneously exposes its own shared layer back to them, so a shared card or form component lives in one place and is loaded once instead of being vendored into three bundles. Concretely, the shell instantiates the federation plugin three times in a single compilation: once as the host that consumes the two remotes, and twice more as containers that publish the common layer and the design-element package under their own entry files. Most setups are host or remote; this one is both in the same build, which means the code the shell already compiles for itself is bit-for-bit the code the remotes import — no separate publish step, no package version to keep in sync across three repos.
Generated federation contracts. The shell's exposes maps are derived by walking the source tree at build time, filtering out tests, demos and config, and registering each module under both ./x and ./x.js, because remotes import with and without the extension. That produces roughly 280 shared modules and 185 design-system elements — about 930 registered keys — from two function calls in the webpack config:
new ModuleFederationPlugin({
name: 'shell', /* host */ remotes: { retail: ..., business: ... },
}),
new ModuleFederationPlugin({
name: 'ui_elements', filename: 'ui-entry.mjs',
exposes: getElements(), // walks the package tree
library: { type: 'module' },
}),
new ModuleFederationPlugin({
name: 'common', filename: 'common-entry.mjs',
exposes: getCommon(), // walks src/common
library: { type: 'module' },
}),A generated contract can't drift from the code it describes, and a hand-written list that long would have rotted within a sprint.
Import maps for singletons rather than Federation's `shared` config. Federation's shared-scope negotiation is a runtime mechanism with a build-time contract; across three separately deployed apps that is a lot of surface for a guarantee as absolute as one copy of the element base class. Instead, the framework and design-system packages are externals that compile to untouched bare specifiers, and a single import map in the shell's document resolves them to versioned, immutable CDN URLs. The shell derives its externals from the map's keys, so the two lists are the same list; the remotes, which never own the map, declare the same package families as regex externals:
// shell — externals are the import map's keys, by construction
const externals = Object.keys(importMap.imports).reduce((acc, key) => {
acc[key] = `import ${key}`; // emit `import x from 'lit'` verbatim
return acc;
}, {});
// remotes — same package families, resolved by the host document's map
const externals = [
/^lit(-element|-html)?(\/.*)?$/,
/^@ds(\/.*)?$/,
];Exactly one runtime instance by construction; design-system code cached across all three apps and across deploys; and a component version bump that is a map edit rather than a rebuild of every app.
Routing is a component map the remotes own. Pages are keyed by CMS content type. Each remote exposes its own map of contentType → { name, action: () => import(...) }, and the shell composes them — merging the retail map into its own namespace, and selecting the business map by top-level path, since business owns a distinct route space:
import { componentMap as retail } from 'retail/entry';
import { componentMap as business } from 'business/entry';
const componentMap = { ...retail, /* shell-owned routes */ };
const getComponentMap = (path) =>
path === 'business' || path === 'offers-hub' ? business : componentMap;This is the part that delivered independent deploys: a product team adds or removes a page by shipping its own remote, and the shell changes only when the chrome or the routing contract changes. It is also what the migration ran on — the shell shipped first, wrapping the existing monolith's pages, and routes then moved into their owning remote one content type at a time, each move a one-line change with an instant revert. No big-bang cutover, no long-lived migration branch.
Two release environments, one build. In production, remotes resolve to same-origin paths under a shared prefix (/micros/retail, /micros/business) — no CORS, no cross-domain CSP surface, and the SPA, its remotes and its API all sit behind one origin. In local development they resolve to env-configured URLs, and a watch-mode plugin pings the shell after each remote rebuild so a three-process dev loop reloads on its own. Watch builds also drop chunk splitting, tree-shaking and side-effect analysis, trading production's output shape for rebuild times that loop is usable at.
A CI gate on duplicate design-system versions. A design system is a tree of packages that depend on each other, so a resolved lockfile can quietly contain two versions of the same element package — which puts the duplicate-registration problem back at install time. A CI step parses the lockfile and fails the build when any design-system package resolves to more than one version. Thirty lines, and the highest-value check in the repo.
Caching is reasoned about in two tiers. A remote entry file has a stable name — it is the resolution point, so it stays at a fixed URL and is revalidated — while everything behind it is content-hashed and immutable. Getting that pairing backwards is how a federated app serves a cached entry pointing at chunks a later deploy already deleted. The generated service worker follows the same logic: it excludes all application JS and CSS from precache and runtime-caches only a narrow class of external content requests, because a precache manifest baked at shell build time would pin users to a module graph two remote deploys stale.
Crawlers get pre-rendered HTML, not a federation graph. Search visibility is a hard requirement on a public product site, and after the split every page component arrives through a runtime import() of a separately deployed remote. Bot user-agents are routed to a server-side pre-render and receive finished HTML while humans get the SPA shell. That decoupling is what let routes move one at a time without an SEO regression riding along with each move.
Impact
- Retail and business release on independent cadences; neither blocks the other's QA.
- Framework and design-system code is downloaded once per user and reused across all three apps and across deploys.
- Design-system upgrades ship as a version bump in one file, without rebuilding the applications.
- The federation contract — ~930 exposed module keys — is generated from the source tree, so it cannot drift from the code it describes.
- Migration ran route by route with per-route revert, behind a routing contract rather than a branch.
