Migrating a monolithic banking frontend to micro-frontends
- Role
- Lead engineer — architecture owner across two teams
- Stack
- Lit 3 web components · Webpack 5 Module Federation · Native ESM · Import maps
- Scope
- Three independently deployed apps across two product teams, ~280 federated shared modules, ~100 design-system packages pinned in one import map
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. I split it into three independently deployed applications without giving up the single-page experience, the single origin, or the single instance of the framework runtime that a shared custom-element library requires.
┌─────────────────────── shell (host) ─────────────────────┐
│ chrome: header · footer · router · search │
│ exposes: common (common-entry.mjs) │
│ ui-elements (ui-entry.mjs) │
│ consumes: retail/* business/* │
└─────────────┬────────────────────────────┬───────────────┘
│ import() │ import()
┌────────┴─────────┐ ┌────────┴───────────┐
│ retail (remote) │ │ business (remote) │
│ retail-entry.mjs │ │ business-entry.mjs │
└────────┬─────────┘ └────────┬───────────┘
└──────── import map ──────┘
▼
framework runtime + ~100 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 Lit doesn't get its own namespace, it collides. The 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 common 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. That inversion is what keeps “shared” from quietly meaning “duplicated three times.”
One build, three federation containers. The shell instantiates the federation plugin three times in a single compilation: once as the host that consumes the two product remotes, and twice more as containers that publish its own 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 between three repos.
Import maps for singletons, not Federation's `shared` config. Federation's shared-scope negotiation is a runtime mechanism with a build-time contract; across three separately deployed apps it is easy to end up with a silently duplicated singleton. Instead, every app declares the framework and design-system packages as externals that compile to untouched bare specifiers:
// webpack.config.js — every app
const externals = Object.keys(importMap.imports).reduce((acc, key) => {
acc[key] = `import ${key}`; // emit `import x from 'lit'` verbatim
return acc;
}, {});A build-time plugin injects the map into <head> so the browser resolves those specifiers to versioned, immutable CDN URLs. Three consequences worth the trade: 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 and redeploy of every app. The same plugin swaps the static map for a dev-only runtime toggle in non-production builds, so QA can repoint any package at a candidate build without a deploy.
Generated federation contracts instead of hand-maintained ones. The shell's exposes map is derived by walking the source tree at build time, filtering out tests, demos and config, and registering each of ~280 modules under both ./x and ./x.js — remotes import with and without the extension, and a mismatch is a runtime 404 rather than a build error. A hand-written list that long would have rotted within a sprint.
Routing as a merged, lazy component map. Pages are keyed by CMS content type; each remote exports its own map of key → () => import(...), and the shell merges them:
import { componentMap as retail } from 'retail/entry';
import { componentMap as business } from 'business/entry';
const componentMap = { ...retail, ...business, /* shell-owned routes */ };This is the part that actually 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.
Strangler migration, keyed on that same map. The shell shipped first, wrapping the existing monolith's pages; routes then moved into their owning remote one content type at a time, each move being a one-line change with an instant revert. No big-bang cutover, no long-lived migration branch.
Guardrails, because distributed builds fail quietly. A CI check parses the lockfile and fails the build if two versions of the same design-system package would ship together. Deterministic module IDs and content-hashed filenames keep long-term caching honest across independent deploys. In production, remotes resolve to same-origin paths under a shared prefix — no CORS, no cross-domain CSP surface; in local dev they resolve to env-configured URLs, with a watch-mode plugin that reloads dependents when the shell rebuilds. Watch builds also drop chunk splitting and tree-shaking outright: a different output shape than production, traded knowingly for rebuild times a three-process dev loop can live with.
Caching has to be reasoned about in two tiers. A remote entry file has a stable name — it is the resolution point, so it must stay at a fixed URL and be revalidated — while everything behind it is content-hashed and immutable. Getting that pairing backwards is precisely how a federated app ends up serving a cached entry that points at chunks a later deploy already deleted. The generated service worker follows the same logic in reverse: it deliberately excludes all app 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. Rather than bet SEO on a crawler executing that graph correctly, bot user-agents are routed to a server-side pre-render and receive finished HTML while humans get the SPA shell. That decoupling is also what let routes move one at a time without an SEO regression riding along with each move.
Working across two teams
Three projects, two teams. Retail and business each own their remote outright and release on their own cadence; the shell is the shared project — co-owned, with both teams releasing it. That co-ownership is the reason the contracts it publishes are generated and gated in CI rather than maintained by convention: a shared project with two release owners is exactly where a hand-written list of ~280 exposed modules rots, and where a duplicated design-system version arrives without anyone having decided to add one.
I set up the architecture end to end — federation topology, import-map strategy, build and dev tooling, CI gates, and the strangler routing the migration ran on. The final step, finishing the setup of one of the product remotes, went to the other team, handed over as written guides and notes: the remote's build configuration, what it has to expose, how it resolves in dev versus production, and how a new route enters the merged component map. The real test of the design was whether another team could complete it from a document rather than from me.
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.
What I'd change
Nothing verifies the host/remote contract before runtime. Generating the exposes map from the source tree is what made that tolerable — a generated contract can't drift from the code it describes, which is most of the risk — but a remote can still rename an exposed module and keep the build green, with the failure landing as a 404. That's the difference between unlikely and checked, and closing it is small: a CI smoke step that boots the shell against freshly built remotes and asserts every route in the merged component map resolves. It's the first thing I'd add.
The failure mode I'd design for explicitly is version skew. Three apps deploy independently, so a tab left open across a remote deploy can hold a shell asking for a chunk hash that no longer exists. The two-tier caching above was chosen partly for this — the entry always resolves, so the blast radius is one stale session rather than a broken deploy — but the user still sees a failed dynamic import. Retaining the previous build's chunks behind the current entry for a deploy or two, plus a reload prompt when an import rejects, closes the gap for the cost of some storage.
