Module Federation and Micro-Frontend Architectures
Module federation moves dependency resolution from compile time to runtime, so a host application can fetch and execute a remote’s chunks over HTTP without rebuilding either side. That single shift — a host loading remoteEntry.js at runtime and negotiating a shared scope — is what lets independently deployed micro-frontends share one React instance, one router, and one design system while keeping separate CI pipelines. For the underlying graph, chunk, and manifest model this builds on, read Core Concepts of Modern Bundling before wiring host/remote boundaries. This guide covers the runtime sharing mechanics, complete @originjs/vite-plugin-federation and Webpack 5 ModuleFederationPlugin setups, a numbered bring-up workflow, the failure modes you will actually hit, and a version compatibility matrix.
The problem federation solves is organizational before it is technical. A single-bundle application forces every team that contributes UI into the same build, the same dependency lockfile, and the same release train: one team’s flaky test blocks everyone’s deploy, and a shared dependency bump has to be negotiated across every feature area at once. The naive escape hatch — give each team its own independently built bundle and stitch them together with iframes or a runtime script loader — works, but it reships the framework in every fragment. Load four such fragments and the browser parses four copies of React, runs four reconcilers, and holds four separate context trees that cannot see each other. Federation is the middle path: separate builds and separate deploys, but a shared runtime that hands every fragment the same framework instance.
Concretely, the mechanism lives at the seam between the module graph and the network. During its build each application still produces a normal chunk graph, but the federation plugin carves out the modules named in exposes and shared and wraps them in an async factory registered on a container object. The host never statically imports the remote; instead it emits a call that, at runtime, fetches remoteEntry.js, reads the container’s manifest of exposed modules, and resolves the requested one — only after both sides have contributed their shared dependencies to a common registry. Everything downstream (which React copy wins, whether a chunk 404s, whether hooks throw) is a consequence of that negotiation, so the rest of this page is organized around it: first the registry and resolution algorithm, then the configuration that feeds it, then the failure modes that appear when the negotiation resolves to something you did not intend.
Prerequisites
Pin the tool versions before touching federation config; resolution behavior changed materially across major versions. Federation is one of the few build features where the exact plugin and bundler version is load-bearing rather than incidental — the share-scope registry format, the semver matching rules, and the default eager behavior all shifted between releases, and a config copied from a blog post written against an older version will fail in ways whose error messages point at the wrong cause. Treat the version list below as part of the contract, not as a suggestion.
- Webpack 5.x (federation is built into
webpack.container; it does not exist in Webpack 4). Use5.80+for stabledependOnandruntimehandling. Node 18 or 20. @originjs/vite-plugin-federation1.3.x with Vite 4 or 5 (Rollup 3/4). This plugin is build-mode only — federated remotes are not served through Vite’s native dev ESM pipeline, so you build remotes andvite previewthem.- For Vite projects that need true dev-mode federation,
@module-federation/vite(the official Module Federation 2.0 plugin) is the alternative; it tracks the runtime used by Webpack and Rspack. - A working understanding of how exports and interop differ across formats, since federated remotes routinely mix CJS and ESM dependencies. See Understanding ESM vs CommonJS in Modern Bundlers.
@module-federation/vite when you need dev-mode parity.Core Mechanics: Shared Scope and Runtime Negotiation
A federated build produces a remote entry (remoteEntry.js) and a container. The container exposes the modules listed under exposes and declares the dependencies it is willing to share. At runtime the host calls get() and init(shareScope) on the container; init is where both sides register their shared dependencies into a common object.
In Webpack that object is __webpack_share_scopes__.default, keyed by package name and then by version: { react: { "18.3.1": { get, loaded, from } } }. When a module requests react, the runtime walks the registered versions, applies the requiredVersion semver range, and returns a single resolved instance. singleton: true collapses this to exactly one version for the whole page and downgrades a version conflict to a console warning instead of loading a second copy — essential for stateful libraries like React, where two instances throw Invalid hook call. The mechanics and pitfalls of that specific case are covered in depth in sharing a singleton React instance across remotes.
The resolution is worth spelling out step by step, because every shared-dependency bug is a deviation from it. First, whoever calls init(shareScope) first seeds the registry: the host normally initializes the default scope during its own startup, and each remote’s init merges its shared entries into that same object rather than replacing it. Registration is additive and idempotent — a version already present with loaded: true is not overwritten. Second, when application code actually evaluates an import of a shared package, the runtime does not immediately return a module; it returns a factory that, on first call, selects a version. Selection sorts the registered versions in descending semver order and picks the highest that satisfies the consumer’s requiredVersion. Third, singleton: true changes selection from per-consumer to per-scope: the first satisfying version is loaded, cached under loaded, and every subsequent consumer receives that exact reference regardless of its own range. This is why the order remotes initialize can decide which React version wins when ranges only partially overlap, and why pinning to identical versions is the only way to make the outcome deterministic. Fourth, strictVersion: true promotes a range mismatch from a warning to a thrown error at load time — useful in CI to make an incompatible remote fail loudly instead of silently double-loading.
Three modifiers on each shared entry control that algorithm and are worth memorizing. requiredVersion is the semver range the consumer will accept; leave it to default from package.json unless you are deliberately widening it. singleton forces one instance per scope and is mandatory for anything that holds module-level state — React, react-dom, react-router, most state stores, and any library using Symbol-based identity checks. strictVersion decides whether a mismatch warns or throws. A fourth, version, lets a remote advertise a version string that differs from its installed one, which you almost never want but which occasionally papers over a transitive-dependency lie until it can be fixed properly.
singleton: true collapses the registry to one instance and downgrades conflicts to a warning.eager controls whether the shared module is bundled into the initial chunk (eager: true) or loaded asynchronously the first time it is requested (eager: false, the default). Eager sharing removes the async boundary requirement but inflates the initial payload and is the usual cause of “Shared module is not available for eager consumption”. Lazy sharing keeps payloads small but forces an async entry (a top-level import('./bootstrap') indirection in Webpack). The reason the async boundary is required at all follows directly from the resolution algorithm above: the share scope must be fully seeded before any consumer selects a version, and a synchronous top-level import evaluates the consumer before the scope’s init has run. The import('./bootstrap') indirection buys exactly one microtask of deferral, which is enough for the runtime to register every shared entry before the first require fires.
@originjs/vite-plugin-federation reproduces this contract on top of Rollup output. It rewrites bare imports of shared packages to a runtime shim, emits a __federation_shared_* map, and fetches remote entries with native dynamic import(). Because it operates on the final Rollup graph, its shared negotiation is simpler than Webpack’s — versions are matched but the per-version registry is flatter — which is exactly why behavior diverges. The full side-by-side is in the Webpack vs Vite module federation comparison.
The flatter registry has a concrete consequence you should plan around: the @originjs plugin resolves a shared package to whichever version the host built with, and does not carry the rich per-version fallback table that Webpack’s runtime maintains. In practice that means the host is the source of truth for every shared singleton, and a remote whose installed version drifts outside the host’s range will not transparently load its own copy the way a non-singleton Webpack share might — it will bind to the host’s instance and either work or break on API differences. That is usually what you want for React, and a footgun for utility libraries with breaking changes between minors. When the versions genuinely must differ, the honest options are to stop sharing that package (let each side bundle its own) or to move to @module-federation/vite, whose runtime restores the version-keyed negotiation.
Configuration & CLI Reference
shared block; only the remote exposes and the host remotes.The configuration surface is deliberately symmetric: the remote and the host both declare a shared block, and the two blocks must agree on which packages are singletons and what ranges they accept. The asymmetry is only in the direction of flow — the remote names what it exposes and emits a remoteEntry.js; the host names the remotes it consumes by name@URL. Get the symmetry wrong and the failures are non-obvious: a package listed as shared on the host but not the remote means the remote silently bundles and ships its own copy, defeating the whole point without any error at all. Read the two Webpack configs below as a pair and diff their shared blocks first — they should be identical.
Webpack 5 host and remote
// webpack.config.js — REMOTE (profile app) // Webpack 5.80+, Node 20+
const { ModuleFederationPlugin } = require("webpack").container;
const deps = require("./package.json").dependencies;
module.exports = {
mode: "production",
output: {
publicPath: "auto", // critical: lets remoteEntry resolve its own chunk URLs
uniqueName: "profile",
},
plugins: [
new ModuleFederationPlugin({
name: "profile",
filename: "remoteEntry.js",
exposes: {
"./Card": "./src/components/Card.tsx",
},
shared: {
react: { singleton: true, requiredVersion: deps.react },
"react-dom": { singleton: true, requiredVersion: deps["react-dom"] },
},
}),
],
};
// webpack.config.js — HOST (shell app) // Webpack 5.80+, Node 20+
const { ModuleFederationPlugin } = require("webpack").container;
const deps = require("./package.json").dependencies;
module.exports = {
mode: "production",
output: { publicPath: "auto" },
plugins: [
new ModuleFederationPlugin({
name: "shell",
remotes: {
// name@URL — the URL must point at the remote's emitted remoteEntry.js
profile: "profile@http://localhost:3001/remoteEntry.js",
},
shared: {
react: { singleton: true, requiredVersion: deps.react },
"react-dom": { singleton: true, requiredVersion: deps["react-dom"] },
},
}),
],
};
// src/App.tsx — HOST: consume the remote lazily with an error boundary
import { lazy, Suspense } from "react";
const RemoteCard = lazy(() => import("profile/Card")); // typed via module declaration
export default function App() {
return (
<Suspense fallback={<div>Loading remote…</div>}>
<RemoteCard />
</Suspense>
);
}
// remotes.d.ts — make the federated import type-check
declare module "profile/Card" {
const Card: React.ComponentType;
export default Card;
}
The Suspense fallback above handles the loading state but not a failed load — a remote origin that is down, a remoteEntry.js that 404s after a bad deploy, or a chunk that fails integrity. A remote can be redeployed independently of the host, so an unreachable remote is a normal runtime condition, not an exceptional one, and it must degrade to a placeholder rather than unmount the shell. Wrap every remote in an error boundary that catches the rejected dynamic import:
// src/RemoteBoundary.tsx — degrade a dead remote to a placeholder // React 18.3+
import { Component, type ReactNode } from "react";
export class RemoteBoundary extends Component<
{ fallback: ReactNode; children: ReactNode },
{ failed: boolean }
> {
state = { failed: false };
static getDerivedStateFromError() {
return { failed: true }; // fired when import('profile/Card') rejects
}
componentDidCatch(err: unknown) {
// ship to your telemetry so a remote outage is visible, not silent
console.error("remote failed to load", err);
}
render() {
return this.state.failed ? this.props.fallback : this.props.children;
}
}
For anything beyond React, prefer the object form of shared over the array form, because the array form gives you no place to set singleton or strictVersion explicitly. The object form makes the negotiation intent auditable and lets CI fail on a drifted version instead of quietly double-loading:
// webpack.config.js fragment — explicit shared negotiation // Webpack 5.80+
shared: {
react: { singleton: true, strictVersion: true, requiredVersion: deps.react },
"react-dom": { singleton: true, strictVersion: true, requiredVersion: deps["react-dom"] },
// a stateful store MUST be a singleton or each remote gets its own store
zustand: { singleton: true, requiredVersion: deps.zustand },
// a pure utility can safely be non-singleton; let ranges resolve independently
"date-fns": { requiredVersion: deps["date-fns"] },
},
Vite host and remote with @originjs/vite-plugin-federation
// vite.config.ts — REMOTE (profile app) // Vite 5.x, plugin 1.3.x
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import federation from "@originjs/vite-plugin-federation";
export default defineConfig({
plugins: [
react(),
federation({
name: "profile",
filename: "remoteEntry.js",
exposes: { "./Card": "./src/components/Card.tsx" },
shared: ["react", "react-dom"], // array form shares with default singleton-like behavior
}),
],
build: {
target: "esnext", // federation requires top-level await support
minify: false, // keep export names readable while bringing the wiring up
cssCodeSplit: false,
},
});
// vite.config.ts — HOST (shell app) // Vite 5.x, plugin 1.3.x
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import federation from "@originjs/vite-plugin-federation";
export default defineConfig({
plugins: [
react(),
federation({
name: "shell",
remotes: {
// bare URL to the built remoteEntry.js served by `vite preview`
profile: "http://localhost:5001/assets/remoteEntry.js",
},
shared: ["react", "react-dom"],
}),
],
build: { target: "esnext" },
});
# Bring up both apps. The plugin is BUILD-ONLY — you cannot use `vite dev` for the remote.
# Remote: build, then preview on a fixed port
npm --prefix ./profile run build
npm --prefix ./profile run preview -- --port 5001 --strictPort # serves /assets/remoteEntry.js
# Host: build and preview, or run dev against the previewed remote
npm --prefix ./shell run build
npm --prefix ./shell run preview -- --port 5000 --strictPort
Bring-Up Workflow
Bring federation up in this order specifically because each step makes the next one debuggable in isolation. Skipping ahead — pointing the host at a remote you have not yet confirmed serves valid JS — collapses two independent failures into one confusing symptom.
- Pin shared versions. Run
npm ls react react-domin every package and confirm a single resolved version. Mismatches here surface later as duplicate instances, and they surface after the wiring appears to work, which is the worst time to discover them. A hoisted monorepo usually resolves to one copy already; a polyrepo with independent lockfiles does not, so this check is where you catch a remote that installedreact@18.2against a host on18.3. Fix drift now by aligning the lockfiles, not later by wideningrequiredVersion. - Build and serve the remote first. Emit
remoteEntry.js, then serve it on a fixed port (--strictPort). Confirmcurl -I http://localhost:5001/assets/remoteEntry.jsreturns200withcontent-type: application/javascript(ortext/javascript). The--strictPortmatters because a plugin that silently increments to the next free port will serve the entry at a URL the host was never told about; you want the serve to fail loudly if the port is taken rather than move. If thecontent-typecomes backtext/plainortext/html, the host’s dynamic import will reject before it parses a byte — fix the server MIME mapping before touching any federation config. - Point the host at that exact URL. The
remotesvalue must resolve to the served entry, not a directory. A trailing-path mismatch yields a 404 on firstimport(). The trap is that@originjsserves the entry under/assets/remoteEntry.jswhile Webpack serves it at the root, so a URL copied between the two stacks will be off by a path segment. Confirm the exact path with the samecurlfrom step 2, then paste that literal string intoremotes. - Consume lazily behind a boundary. Wrap remote imports in
React.lazy+Suspenseand an error boundary so a remote outage degrades to a fallback instead of a blank shell. This is not optional polish: without the boundary, a single rejected remote import propagates up and unmounts the host, so one team’s bad deploy takes down every other team’s UI on the same page. TheRemoteBoundarycomponent above is the minimum; production shells usually add a retry with backoff and a stale-cache fallback. - Verify the shared scope. In the browser console run
Object.keys(__webpack_share_scopes__.default)(Webpack) or inspect the__federation_shared_*global (Vite) and confirmreactappears exactly once. This is the single most valuable check on the page: it distinguishes “federation is wired” from “federation is working”. Ifreactshows two version keys, or ifimport * as Reactfrom the host and from inside the remote return different object identities (hostReact === remoteReactisfalse), you have a duplicate instance and hooks will throw the moment the remote renders. - Lock the contract in CI. Add a smoke test that fetches
remoteEntry.jsand asserts it is non-empty JS, plus a runtime assertion thatReactresolves to one instance. The contract you are locking is the pair of URLs and the shared version ranges; both drift silently as teams deploy, so an automated check is the only thing that turns a broken integration into a red build instead of a production incident. Run it against a preview deploy of both apps, not against local builds, so it exercises the real cross-origin path.
Debugging & Failure Modes
Shared module is not available for eager consumption
The symptom is a build-time or first-paint error naming a shared package. The root cause is that a shared dependency is imported synchronously in the entry before the share scope is initialized: the runtime reaches a require for react while the registry is still empty, and because the module was declared shared it refuses to fall back to a static import. The fix is to introduce an async boundary — move app startup into bootstrap.tsx and make the entry a single import("./bootstrap") — so the scope’s init completes in the first microtask before any consumer evaluates. Only set eager: true if you accept the larger initial chunk and genuinely cannot add an async boundary (some server-rendered entry points). Confirm the fix by checking that the entry chunk contains only the import("./bootstrap") call and no direct React import, and that the error is gone on a hard reload.
Failed to fetch dynamically imported module: .../remoteEntry.js
The symptom is a rejected promise on the first render of a remote, with the remote’s URL in the message. The host could not load the remote entry, and there are three independent causes worth checking in order. First, the URL in remotes may not match the served path exactly — re-run the curl -I from the bring-up workflow and paste the literal 200-returning path. Second, the browser may be blocking the cross-origin request: the remote must send Access-Control-Allow-Origin (set server.cors: true and server.origin in Vite, or add CORS headers on the preview/CDN in front of the remote), and a missing header shows up in the console as a CORS error rather than a 404. Third, the response content-type may be text/plain or text/html from a misconfigured proxy or SPA fallback that rewrote the request to index.html — the dynamic import rejects because the body is not a module. Confirm by opening the remoteEntry.js URL directly in a new tab: you should see JavaScript, not an HTML page or a download prompt.
Invalid hook call / two React copies
The symptom is a thrown Invalid hook call the instant a remote component first renders, sometimes accompanied by a warning about multiple copies of React. Two React instances are live because singleton was not honored or requiredVersion ranges did not overlap, so the registry resolved the host and the remote to different instances. Confirm both sides list react and react-dom under shared with singleton: true, and that the resolved versions satisfy each other’s range; then verify at runtime that the host’s React and the remote’s React are the same object reference, not merely the same version string. A subtle variant: a shared package that itself imports React (an icon or UI library) must also be a singleton or it drags in a second copy transitively. The complete reconciliation procedure is in sharing a singleton React instance across remotes.
Remote chunk 404s under a nested base path
The symptom is that remoteEntry.js loads fine but the chunks it then requests 404, usually only in production behind a path prefix or CDN. The root cause is that the remote emitted chunk URLs relative to the wrong base: it hard-coded a base at build time that does not match where it was actually deployed. In Webpack set output.publicPath: "auto", which defers base resolution to the runtime document.currentScript location so the entry computes its own sibling URLs. In Vite set base to the absolute deployment path of the remote (for example /apps/profile/) so remoteEntry.js resolves its sibling chunks against the right prefix. Confirm by loading the deployed host and checking the Network tab: every chunk request from the remote origin should return 200, and none should be prefixed with the host’s base instead of the remote’s.
Remote works locally but stale after deploy
The symptom is that a freshly deployed remote change does not appear in the host, or appears intermittently across users. The cause is caching: remoteEntry.js is the one file that must never be content-hashed, because the host references it by a fixed URL, yet CDNs happily cache it with a long TTL. Serve remoteEntry.js with a short or no-cache header while letting its content-hashed sibling chunks cache forever — the entry is a tiny manifest, so caching it buys nothing and costs you every deploy’s freshness. Confirm by diffing the etag or the hashed chunk names inside remoteEntry.js before and after a deploy; if the host still fetches the old manifest, the cache header is the culprit, not the build.
Performance Impact & Measurement
Federation’s win is payload deduplication, not magic. A correctly shared react + react-dom removes roughly 45 KB gzipped per remote that would otherwise ship its own copy; across four remotes that is the difference between one and four framework copies on the wire. Measure it concretely:
- Use the Network tab to confirm
react-domis requested once across all origins. A second request for areact-domchunk from a remote origin is a deduplication failure. - Enable
build.reportCompressedSizein Vite and diff gzipped remote payloads against a monolithic baseline. Target initial remote payloads under ~150 KB gzipped. - Wrap each remote’s
import()inperformance.mark()/performance.measure()and watch for entries over ~300 ms, which usually mean a cold remote origin or an un-preloaded entry.
The cost side is real: an extra HTTP round trip per remote entry, and lazy shared modules add an async waterfall. The waterfall is the sneaky cost — the host must fetch its own bundle, then fetch remoteEntry.js, then read the manifest, then fetch the exposed chunk, then fetch any shared chunk that was not already loaded. Four sequential round trips on a cold connection is easily 400 ms before the remote paints. Preload critical remoteEntry.js files with <link rel="modulepreload"> in the host’s HTML so the entry fetch overlaps the host’s own bundle download instead of following it:
<!-- host index.html — warm the remote entry in parallel with the host bundle -->
<link rel="modulepreload" href="http://localhost:5001/assets/remoteEntry.js" crossorigin />
Only preload the entries you are confident the first view needs; preloading every remote reintroduces the eager-payload problem you used lazy sharing to avoid. For remotes gated behind a route or an interaction, leave them lazy and let the boundary’s fallback cover the fetch.
When Not to Use Federation
Federation earns its complexity only when independent deployment is a hard requirement — separate teams shipping on separate cadences to the same page. It is the wrong tool for a codebase that deploys as one unit. If every fragment releases together, a monorepo with route-level code splitting gives you the same lazy-loading and payload benefits with none of the runtime negotiation, cross-origin CORS, or version-drift failure modes described above; the module graph stays static and the bundler can tree-shake across the whole app, which federation’s request-time boundary prevents.
Two specific situations argue against it even when you do have multiple teams. First, if the teams cannot agree to pin shared framework versions in lockstep, the singleton negotiation becomes a running source of Invalid hook call incidents, and you are better served by hard iframe isolation where each fragment ships its own everything. Second, if the shared surface is large and changes often — a design system whose API churns weekly — the runtime contract between host and remotes becomes a distributed-versioning problem that a compile-time monorepo would catch at build. Reach for federation when the shared surface is small, stable, and singleton-shaped (the framework, the router, the auth client) and the divergent surface is large; invert that ratio and the costs dominate.
A useful comparison point is native import maps. An import map also lets the browser resolve a bare specifier to a URL at runtime, and for pure ESM libraries with no shared state it is dramatically simpler than federation — no build plugin, no container, no share scope. What import maps do not give you is negotiated singleton resolution across independently built graphs or the exposes/get/init container contract, so they cannot dedupe a stateful React across four separately deployed remotes. Use import maps when you only need URL indirection for stateless dependencies; use federation when you need the runtime to guarantee one instance of a stateful one.
Compatibility Matrix
@originjs plugin lacks dev-mode federation — the others support it.| Stack | Federation mechanism | Dev-mode federation | Node | Notes |
|---|---|---|---|---|
| Webpack 5.80+ | Built-in ModuleFederationPlugin |
Yes (webpack-dev-server) |
18 / 20 | Mature share-scope registry; CJS-friendly |
Vite 5 + @originjs/vite-plugin-federation 1.3.x |
Rollup output shim | No — build + vite preview |
18 / 20 | target: esnext required (top-level await) |
Vite 5 + @module-federation/vite |
MF 2.0 runtime | Yes | 18 / 20 | Tracks Webpack/Rspack runtime; better dev parity |
| Rspack 1.x | Built-in MF (Webpack-compatible) | Yes | 18 / 20 | Drop-in for Webpack configs; faster builds |
The practical decision reduces to one axis: whether you need federation to work in the dev server, not just in a preview build. If your team’s inner loop depends on hot-reloading a remote while editing the host, the @originjs build-only plugin will frustrate everyone, because every remote change requires a rebuild and a vite preview restart — choose @module-federation/vite or Rspack instead. If federation only needs to hold together in staging and production and the dev loop runs each app standalone, the @originjs plugin is the lightest option and its build-only limitation never bites. Rspack is the low-friction migration target for an existing Webpack federation setup: the config is largely drop-in and the build is materially faster, which matters when every remote’s CI runs a full federated build on each push.
Related
- Webpack vs Vite Module Federation Comparison — bundle-time vs request-time federation, with exact error fixes for each.
- Sharing a Singleton React Instance Across Remotes — eliminating duplicate React and
Invalid hook callacross federated boundaries. - Understanding ESM vs CommonJS in Modern Bundlers — why mixed-format remotes break interop and how to align
exportsfields. - Tree-Shaking Mechanics and Dead Code Elimination — keeping shared chunks lean across distributed module graphs.
- Core Concepts of Modern Bundling — the graph, chunk, and manifest model federation is layered on.