Portfolio/Writing/Module Federation: the trade-offs, and how it actually works

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.

ts
// 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:

js
// 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:

js
__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.0 and the scope offers 18.2.0, it uses the shell's copy and never loads its own.
  • singleton: true forces 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: true turns a mismatch into a hard runtime error instead of a warning.
  • requiredVersion defaults to the version in your package.json, which catches people out when a lockfile bump quietly changes negotiation.
  • eager: true puts the shared module in the initial bundle instead of an async chunk. You need it when you cannotawait before first use, and it costs you code-splitting.
text
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 React
The load sequence for a single federated import. The host owns the share scope; remotes borrow from it.

How 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 / RspackVite plugin
RuntimeNative `__webpack_require__` + sharing runtimeShim that emulates the container protocol
Dev serverWorks as-isRemotes often need `build --watch` + preview; pure `vite dev` federation is limited
Shared depsRich semver negotiation, fallbacks, `strictVersion`Coarser; version handling historically weaker, improving with MF 2.0 runtime
Module formatAny targetESM only; the host and remotes must agree
ChunkingWebpack splitChunksRollup output, different granularity; watch for duplicated vendor code
The Vite dev-mode gotcha
With @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 singleton violation throws during init(), before your error boundary mounts.
  • CORS and crossorigin on the remote entry script, and a CSP script-src that 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 ifPrefer a monorepo / build-time integration if
Multiple teams must deploy on independent schedules todayOne team, or teams that already release together
A large legacy app is being strangled route-by-route by a new stackGreenfield app that just feels big
You need to hot-swap a feature without redeploying the shellA redeploy of the whole app takes minutes and nobody minds
You can staff a platform team to own the shared contract and runtimeNo 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#

  1. Platform libraries: the exact list of singleton shared deps (React, react-dom, router, state, i18n, design system) and who owns their version.
  2. Contract versioning: how remoteEntry.js URLs are versioned and how a breaking exposed-API change is rolled out.
  3. Types: automated .d.ts generation and publishing per remote, checked in CI.
  4. Failure UX: a standard error boundary and skeleton for "remote failed to load".
  5. Observability: which remote and version rendered, surfaced in error reports and RUM.
  6. Local dev: a documented command that brings up host plus remotes at compatible versions.
  7. CSP and CORS: the allowlist of remote origins, kept with the deploy config.
Summary
Module Federation is an elegant runtime module-sharing system. Adopt it for independent team deployment cadence, budget for a platform owner and for new runtime failure modes, and never turn it on without first writing down your shared-dependency contract.

Next up
Giving developers Claude Code is not a design handoff

Handoff was never "here is a tool that can read Figma and write code." It was a transfer of intent, constraints and edge cases from one head to another, and most of that transfer still happens nowhere. Seating an agent between design and engineering does not close the gap, it moves where the gap gets discovered, usually into production. What handoff was actually for, where it breaks now, and what a contract that a human and an agent can both build from looks like.

Read next →