Module Federation: the trade-offs, and how it actually works
A ground-up look at Webpack Module Federation: the runtime container, remoteEntry, the shared scope and semver negotiation. Plus how the Vite implementation differs, and the failure modes that decide whether it is worth adopting.
Module Federation lets one build consume JavaScript modules from another build at runtime, over the network, without either side publishing an npm package or sharing a bundler. It is the mechanism most teams reach for when they want independently deployable micro-frontends that still feel like one app in the browser.
It is also adopted for the wrong reasons a lot, and then blamed for problems that are really about how the teams are organised. This post walks through what it does at the module level, then lays out the trade-offs in enough detail to make an honest decision.
The problem it solves#
Say you have a shell app and a "checkout" app owned by different teams. You want checkout deployed on its own cadence, but rendered inside the shell with no iframe and no full-page navigation. Every option before Module Federation was a compromise.
- Publish checkout as an npm package. Now every checkout release needs a shell rebuild and redeploy. The teams are coupled at build time, which is the thing you were trying to avoid.
- iframes. Hard isolation, but you fight routing, auth propagation, resize, shared design tokens, and focus management forever.
- Runtime script injection with globals. Works, but you hand-roll dependency sharing, and two copies of React is a matter of time.
Module Federation is Webpack (and now Rspack, and via plugins Vite and esbuild) giving that third option a real contract. One build can expose modules, another build can import them lazily, and the libraries they share are negotiated so you ship one React, not three.
How it works under the hood#
Three concepts do all the work: the container, the remote entry, and the share scope.
The container and remoteEntry.js
When you add exposes to the plugin config, Webpack emits an extra entry file, conventionally named remoteEntry.js. That file is a container: a tiny module with two methods.
// The shape every federated container implements
type Container = {
// Register the host's shared modules into this container's scope
init(shareScope: Record<string, unknown>): Promise<void>
// Resolve one of the exposed modules; returns a factory
get(module: string): Promise<() => unknown>
}The container does not contain the exposed code. It holds a map from exposed name ("./Checkout") to an async chunk loader. Call get("./Checkout") and it dynamically imports the chunk that holds that module, then hands you a factory that returns the module's exports. That is why a remote can expose a 400 KB feature and cost the host almost nothing until something renders it.
Consuming a remote
On the host, remotes: { checkout: "checkout@https://…/remoteEntry.js" } makes import("checkout/Checkout") compile to roughly this:
// Simplified version of what Webpack generates for import("checkout/Checkout")
async function loadCheckout() {
await __webpack_init_sharing__("default") // 1. build host share scope
const container = await loadRemoteEntry( // 2. fetch + eval remoteEntry.js
"https://cdn.example.com/checkout/remoteEntry.js"
)
await container.init(__webpack_share_scopes__.default) // 3. give remote our shared libs
const factory = await container.get("./Checkout") // 4. resolve the exposed module
return factory() // 5. { default: CheckoutApp, ... }
}Steps 1 and 3 are the interesting ones. Before any remote code runs, the host publishes the shared libraries it knows about into a share scope object, and every remote gets that same object passed into its init().
The share scope and version negotiation
A share scope is a registry keyed by package name, then by version:
__webpack_share_scopes__.default = {
react: {
"18.2.0": {
get: () => Promise.resolve(() => ReactModule), // lazy factory
loaded: true,
from: "shell",
shareConfig: { singleton: true, requiredVersion: "^18.2.0", strictVersion: false },
},
},
"react-dom": { /* … */ },
}When the checkout remote needs React, it does not reach for its own bundled copy first. It looks in the shared scope, sees the versions on offer, and picks one according to its shared config.
- Highest satisfying semver wins. If the remote asks for
^18.0.0and the scope offers18.2.0, it uses the shell's copy and never loads its own. singleton: trueforces exactly one instance even when versions technically mismatch. It is mandatory for anything that keeps module-level state: React itself,react-dom, a router, your state library, an emotion or styled-components cache.strictVersion: trueturns a mismatch into a hard runtime error instead of a warning.requiredVersiondefaults to the version in yourpackage.json, which catches people out when a lockfile bump quietly changes negotiation.eager: trueputs the shared module in the initial bundle instead of an async chunk. You need it when you cannotawaitbefore first use, and it costs you code-splitting.
host bundle share scope (default) checkout remote
────────── ───────────────────── ──────────────
init sharing ─────────▶ { react: 18.2.0 (shell) }
│
fetch remoteEntry.js ─────────────┼──────────────────────▶ container
container.init(scope) ────────────┼──────────────────────▶ registers its
│ react 18.3.0 too
container.get("./Checkout") ──────┼──────────────────────▶ resolve chunk
▼
react: pick 18.3.0 (highest satisfying ^18)
│
CheckoutApp() renders ◀──────────┘ using ONE shared ReactHow the Vite implementation differs#
Vite has no first-party Module Federation. The common plugin is @originjs/vite-plugin-federation; @module-federation/vite is a newer option aligned with the Module Federation 2.0 runtime. The Webpack mental model mostly carries over, but the machinery underneath is different and the differences leak.
| Webpack / Rspack | Vite plugin | |
|---|---|---|
| Runtime | Native `__webpack_require__` + sharing runtime | Shim that emulates the container protocol |
| Dev server | Works as-is | Remotes often need `build --watch` + preview; pure `vite dev` federation is limited |
| Shared deps | Rich semver negotiation, fallbacks, `strictVersion` | Coarser; version handling historically weaker, improving with MF 2.0 runtime |
| Module format | Any target | ESM only; the host and remotes must agree |
| Chunking | Webpack splitChunks | Rollup output, different granularity; watch for duplicated vendor code |
@originjs/vite-plugin-federation a remote generally has to be built (vite build --watch) and served via vite preview for the host to consume it. Expecting HMR across the federation boundary in plain vite dev is the number-one setup frustration. The MF 2.0 Vite runtime narrows this gap, but check the current state before you promise your team a smooth local loop.The trade-offs#
1. Shared-dependency version skew is now a distributed problem
With one build, a dependency upgrade is one PR. With federation, the shell and five remotes each pin their own React, their own router, their own design-system version. The share scope hides this until it does not. A remote built against React 18.3 assumes an API the shell's shared 18.2 does not have, and you get a runtime crash in production that no CI caught, because CI built each app in isolation. You need a shared contract for the platform libraries and a way to roll them forward across every remote at roughly the same time. That is coordination, which is the thing federation was supposed to remove.
2. Deploy coupling moves, it does not disappear
Remotes are independently deployable, yes. But the host caches remoteEntry.js, the remote's exposed module contract is now a public API, and a breaking change to an exposed component's props breaks the host with no compile step to catch it. Teams end up versioning the remote entry URL (/checkout/v3/remoteEntry.js) or standing up a manifest service. Either way it is infrastructure you have to build and operate.
3. Type safety stops at the boundary
import("checkout/Checkout") is any unless you generate and publish .d.ts files for every exposed module and wire them into each consumer (@module-federation/typescript, or the built-in DTS plugin in MF 2.0). It works, but it is a build step per remote, it can go stale, and a mismatch between the published types and the deployed code is worse than no types at all.
4. Runtime failure modes are new
- The remote's CDN is down or slow, so the host needs a real fallback UI per remote, not a white screen.
- A
singletonviolation throws duringinit(), before your error boundary mounts. - CORS and
crossoriginon the remote entry script, and a CSPscript-srcthat now has to allow every remote origin. - Two remotes each think they own the singleton because load order changed.
5. Debugging spans repos and source maps
A stack trace runs from host code into a chunk served from another origin, built by another pipeline, possibly with source maps disabled in prod. Reproducing locally means running the host plus N remotes at the right versions. It is a real, recurring tax on every incident.
6. SSR multiplies the complexity
Server-side rendering with federation means the Node server also has to load remote containers, share a server-side scope, and keep the client and server share scopes consistent for hydration. It is possible, and Module Federation 2.0 invests heavily here, but it is a large step up in difficulty from the client-only case.
7. It encodes your org chart
Module Federation is a solution to a team-topology problem that is wearing a bundler costume.
If you are one team of eight people, federation adds infrastructure, failure modes, and version-negotiation subtlety to buy an independent deployability you do not need. A monorepo with good code-splitting and a fast pipeline gets you most of the isolation with none of the runtime risk. Federation earns its keep when independent deployment cadence between teams is a real, current constraint, not an aspiration.
When it is the right call#
| Reach for Module Federation if | Prefer a monorepo / build-time integration if |
|---|---|
| Multiple teams must deploy on independent schedules today | One team, or teams that already release together |
| A large legacy app is being strangled route-by-route by a new stack | Greenfield app that just feels big |
| You need to hot-swap a feature without redeploying the shell | A redeploy of the whole app takes minutes and nobody minds |
| You can staff a platform team to own the shared contract and runtime | No one owns cross-cutting concerns yet |
| Remotes are separate products (dashboard, billing, admin) | The "remotes" are really just feature folders |
If you adopt it, decide these up front#
- Platform libraries: the exact list of
singletonshared deps (React, react-dom, router, state, i18n, design system) and who owns their version. - Contract versioning: how
remoteEntry.jsURLs are versioned and how a breaking exposed-API change is rolled out. - Types: automated
.d.tsgeneration and publishing per remote, checked in CI. - Failure UX: a standard error boundary and skeleton for "remote failed to load".
- Observability: which remote and version rendered, surfaced in error reports and RUM.
- Local dev: a documented command that brings up host plus remotes at compatible versions.
- CSP and CORS: the allowlist of remote origins, kept with the deploy config.