Webpack & Vite Module Federation Implementation #
Module Federation lets a host application load code from independently built and deployed remote applications at runtime, over the network, while sharing common libraries like React so they load once. It is the mechanism that turns a set of separately owned frontend codebases into a single coherent product without a shared build step or a synchronized release train.
This guide is for frontend architects and tech leads who have already decided that some form of micro-frontend composition is warranted and now need to choose and implement the federation layer. It covers when Module Federation is the right tool versus when a lighter approach wins, how the host/remote model works, the canonical configuration for both Webpack 5 and Vite, the team topology that keeps it sustainable, and a staged adoption path with explicit go/no-go gates.
If you are still deciding whether to split your frontend at all, start with the Core Micro-Frontend Architecture Tradeoffs guide first. Federation is an implementation detail; the boundary decision comes before it.
remoteEntry.js at runtime and shares one React instance; runtime composition decouples deploys, build-time composition does not.When Module Federation Is Warranted (and When It Is Not) #
Module Federation buys you one thing above all else: independent deployment of frontend code owned by different teams, with shared libraries loaded once. Everything else it costs you — network waterfalls, version negotiation, observability gaps — is the price of that benefit. So the decision criteria are about whether you actually need independent deployment.
Use these thresholds as a starting point:
- Team count. Federation earns its keep at three or more stream-aligned teams each owning a distinct business domain. With one or two teams, a monorepo with code splitting gives you most of the modularity at a fraction of the operational cost.
- Release cadence. If teams ship to production several times a week and are blocked by each other’s release windows, decoupling deploys is worth real money. If you release weekly as a unit, the coordination problem federation solves does not yet exist.
- Domain complexity. Clear bounded contexts — checkout, search, account, dashboard — map cleanly onto remotes. A frontend that is really one tightly interwoven workflow does not split well no matter how many people work on it.
When fewer than two of those hold, the honest answer is usually “not yet.” The detailed argument lives in when to avoid micro-frontends for small teams, and it is worth reading before you commit a quarter of engineering time to a federation rollout.
There is also a maturity prerequisite that the thresholds above assume. Federation moves complexity from build time to runtime and into your operations stack. You need CDN control to manage manifest caching, observability to see which remote failed, and a CI pipeline disciplined enough to catch breaking changes before they ship. A team that cannot yet reliably deploy a single monolith will not suddenly do better with several independently deployed remotes — they will have multiplied the number of things that can break in production. The right sequencing is: get deployment, caching, and observability solid on the monolith first, then federate. Federation rewards operational maturity and punishes its absence.
Core Concept Map: Host, Remote, and the Three Composition Models #
Three roles and three composition models cover almost everything you need to reason about.
Host and remote #
A host (sometimes called a shell or container) owns the page: routing, top-level layout, authentication, and the decision of which remotes to load and when. A remote exposes one or more modules — usually presentation components, occasionally a route subtree — through a generated manifest file, conventionally named remoteEntry.js. A single application can be both: a remote can host its own sub-remotes.
The host never imports a remote’s source at build time. It knows only the remote’s name and the URL of its remoteEntry.js. That file is a small manifest that, when executed, tells the host how to fetch the actual exposed modules and which shared dependencies the remote expects. This indirection is exactly what allows the remote to be rebuilt and redeployed without rebuilding the host.
It helps to picture the runtime sequence concretely. The host renders, hits a point where it needs the checkout widget, and asks the federation runtime for checkout/CheckoutWidget. The runtime fetches checkout/remoteEntry.js if it has not already, executes it to register the checkout container on window, initializes that container against the shared scope, asks the container for the ./CheckoutWidget factory, and finally calls the factory to get the real module. Only at that last step does the remote’s actual component code download. Every layer of that chain is a place where caching, version negotiation, or a network failure can intervene — which is why the failure handling around remote loading is not optional polish but core architecture.
The roles are also recursive in a useful way. A remote that itself composes sub-features can declare its own remotes, becoming a host for them. This lets a large domain like an admin console be a single remote from the top-level shell’s perspective while internally being its own small federation. Keep the nesting shallow, though; every layer adds a manifest fetch and a share-scope negotiation, and debugging a three-deep federation is genuinely hard.
Build-time vs runtime vs iframe composition #
There are three ways to put independently developed frontends on one page, and Module Federation is only one of them.
- Build-time composition publishes each piece as an npm package and bundles them together. Simple and fast at runtime, but every change to any piece requires rebuilding and redeploying the whole. This is not micro-frontends in the deployment sense — it is a modular monolith.
- Runtime composition is what Module Federation does: the host fetches and stitches remotes in the browser at runtime. Independent deploys, shared singletons, real coupling risk around versions. This guide is mostly about this model.
- Iframe composition isolates each app in its own browsing context. Bulletproof isolation of CSS, globals, and crashes, at the cost of clumsy cross-frame communication, duplicated dependencies, and awkward layout. Useful as a fallback for legacy or hostile third-party content, rarely the primary strategy.
Module Federation is the runtime option that also solves dependency sharing, which is why it dominates. But for a small set of third-party widgets or a single rarely-changing app, import maps or even an iframe can be the lower-cost answer.
The share scope, briefly #
The one concept that ties host and remote together at runtime is the share scope. When the host boots, it initializes a shared registry — by default named default — and registers every dependency it declared as shared. As each remote initializes, it does the same, but instead of blindly registering its own copy, it checks the scope first. If a compatible version is already present, the remote reuses it; if not, and the dependency is not a singleton, it registers its own. This negotiation is why two apps built months apart can still share a single React instance, and it is also why a misconfigured requiredVersion quietly produces a second copy. Understanding that the scope is populated lazily, in initialization order, explains most of the surprising behavior you will hit in production.
Canonical Webpack 5 Configuration #
This is the reference host and remote configuration for Webpack 5. The plugin ships in the webpack package under webpack.container.
// host/webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;
const deps = require('./package.json').dependencies;
module.exports = {
output: {
// 'auto' makes the remote infer its own public path from where it was
// loaded, so the same build works from any CDN origin.
publicPath: 'auto',
},
plugins: [
new ModuleFederationPlugin({
name: 'host',
// Map a logical remote name to the URL of its manifest. Resolved at
// build time; the actual fetch happens at runtime.
remotes: {
checkout: 'checkout@https://cdn.example.com/checkout/remoteEntry.js',
search: 'search@https://cdn.example.com/search/remoteEntry.js',
},
shared: {
// singleton: one instance across host + all remotes. Mandatory for
// anything that holds module-level state — React, the router, stores.
react: {
singleton: true,
requiredVersion: deps.react, // negotiate against the real version
},
'react-dom': {
singleton: true,
requiredVersion: deps['react-dom'],
},
},
}),
],
};
The remote side declares what it exposes rather than what it consumes:
// checkout/webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;
const deps = require('./package.json').dependencies;
module.exports = {
output: { publicPath: 'auto' },
plugins: [
new ModuleFederationPlugin({
name: 'checkout',
filename: 'remoteEntry.js', // the manifest the host fetches
exposes: {
// public path -> source module. Keep this list small and presentational.
'./CheckoutWidget': './src/CheckoutWidget',
},
shared: {
react: { singleton: true, requiredVersion: deps.react },
'react-dom': { singleton: true, requiredVersion: deps['react-dom'] },
},
}),
],
};
A few things carry most of the weight here. eager defaults to false, which is what you want: shared dependencies load asynchronously when first needed, keeping the initial chunk lean. Setting eager: true pulls a dependency into the host’s initial bundle — only do that for the one app entry that must be synchronous, and only with an async boundary (import('./bootstrap')) wrapping the real app code. singleton: true guarantees one React instance; without it, two copies of React on a page produce the dreaded “Invalid hook call” error. requiredVersion is the contract: at runtime the share scope picks the highest compatible version and warns if a remote demands something incompatible.
Keep exposes short. Every exposed module is a public API you now have to version. Expose finished components, not utilities, hooks, or API clients — those leak internals and create the cross-team coupling federation was supposed to remove. The full walkthrough, including the async bootstrap pattern, is in Configuring Webpack Module Federation.
The Vite Federation Equivalent #
Vite has no native federation primitive; it uses a plugin, most commonly @module-federation/vite or @originjs/vite-plugin-federation. The shape mirrors Webpack closely, which makes mixed Webpack/Vite estates practical.
// checkout/vite.config.ts
import { defineConfig } from 'vite';
import { federation } from '@module-federation/vite';
export default defineConfig({
plugins: [
federation({
name: 'checkout',
filename: 'remoteEntry.js',
exposes: {
'./CheckoutWidget': './src/CheckoutWidget.tsx',
},
shared: {
react: { singleton: true, requiredVersion: '^18.2.0' },
'react-dom': { singleton: true, requiredVersion: '^18.2.0' },
},
}),
],
build: {
// The federation runtime relies on top-level await; esnext enables it.
target: 'esnext',
// Disable module preload polyfill quirks that break remote chunk loading.
modulePreload: false,
},
});
The host config is the symmetrical case with a remotes map instead of exposes:
// host/vite.config.ts
import { defineConfig } from 'vite';
import { federation } from '@module-federation/vite';
export default defineConfig({
plugins: [
federation({
name: 'host',
remotes: {
checkout: {
type: 'module',
name: 'checkout',
entry: 'https://cdn.example.com/checkout/remoteEntry.js',
},
},
shared: {
react: { singleton: true, requiredVersion: '^18.2.0' },
'react-dom': { singleton: true, requiredVersion: '^18.2.0' },
},
}),
],
build: { target: 'esnext' },
});
The important differences from Webpack are operational rather than syntactic. Vite serves unbundled ES modules in development, so the dev server is dramatically faster to start, but remotes have to be built (not just dev-served) before the host can consume them locally — the federation plugin produces the remoteEntry.js only at build time. That means a typical Vite workflow runs vite build --watch on remotes and vite dev on the host, or builds remotes once and points the host at the output. Plan your local scripts around that asymmetry. The full setup, including the build/preview loop and chunking trade-offs, is covered in Setting Up Vite with Federation Plugins.
Loading a Remote at Runtime #
Whichever bundler you use, the host pulls a remote in the same conceptual steps: initialize the shared scope, fetch and init the container, get the exposed factory, render. With Webpack you can rely on the runtime helpers; the modern federation runtime exposes a tidier loadRemote API. A defensive version always wraps the call in an error boundary:
// host/src/loadRemote.js
async function loadRemote(scope, module) {
await __webpack_init_sharing__('default');
const container = window[scope];
if (!container) {
throw new Error(`Remote "${scope}" failed to load`);
}
await container.init(__webpack_share_scopes__.default);
const factory = await container.get(module);
return factory();
}
In React, render the result through React.lazy so a remote that is slow or offline degrades to a fallback rather than taking the page down:
// host/src/RemoteWidget.tsx
import { lazy, Suspense } from 'react';
import { ErrorBoundary } from './ErrorBoundary';
const CheckoutWidget = lazy(() => loadRemote('checkout', './CheckoutWidget'));
export function RemoteWidget() {
return (
<ErrorBoundary fallback={<CheckoutPlaceholder />}>
<Suspense fallback={<Spinner />}>
<CheckoutWidget />
</Suspense>
</ErrorBoundary>
);
}
Treat every remote boundary as a network boundary: it can fail, time out, or return a stale build, and the host must survive all three. The error boundary catches a remote that throws on load or render; the suspense fallback covers the latency of the fetch. Wire a timeout into loadRemote itself so a hung CDN does not leave the spinner up forever. Capturing those failures with telemetry rather than a blank screen is the difference between a resilient system and a fragile one, and it is what lets you reason about which remote degraded when a page looks wrong.
Team Topology and Ownership #
Federation is a socio-technical pattern; the org chart it implies is as important as the config.
A platform team owns the guardrails that span all remotes: the shared dependency policy (which libraries are singletons and at what version range), the remoteEntry.js caching rules, the CDN and publicPath conventions, the contract-testing harness, and the host shell itself. They do not own product features. Their product is the paved road.
Stream-aligned teams each own one remote end to end — code, build, deploy, runtime behavior, and on-call. They consume the platform team’s guardrails and expose a small, versioned set of components. The boundary between them is the manifest: the platform team defines how remotes are loaded and shared; stream teams define what each remote contains.
This split keeps build-time coupling near zero while preserving system-level consistency. The communication contract between remotes — events, shared context, or a thin store — should be deliberately narrow and explicitly versioned. Most cross-remote pain traces back to a leaky contract, which is why Cross-App State & Context Sharing is worth treating as its own design exercise rather than an afterthought. Decoupling the teams without fracturing the user experience is addressed directly in decoupling frontend teams without sacrificing UX.
Conway’s Law is not optional advice here; federation makes it load-bearing. The module boundaries you draw in exposes and remotes will end up mirroring your communication structure whether you plan it or not, so plan it. If two teams find themselves constantly coordinating changes across a remote boundary, that boundary is in the wrong place — the fix is org and domain re-design, not more clever configuration. The most successful federation rollouts spend more time on where the seams go than on the bundler config that implements them.
A healthy ownership model also assigns a single owner to each shared singleton’s version policy. When “who gets to bump React” is ambiguous, every team bumps independently and the share scope quietly fragments. Make the platform team the version authority for the small set of true singletons, and let stream teams own everything else freely. That one rule prevents the slow drift that turns a clean federation into a pile of duplicated dependencies a year later.
Strategic Tradeoffs: Webpack vs Vite vs Import Maps #
There is no universally correct federation technology. The choice is a function of your existing toolchain, your appetite for ecosystem maturity versus dev-server speed, and how much sharing you actually need.
| Dimension | Webpack 5 Module Federation | Vite + federation plugin | Native import maps |
|---|---|---|---|
| Maturity | Production-proven, first-party | Plugin-based, maturing fast | Browser-native, stable spec |
| Dev server speed | Slower cold start (bundles) | Very fast (unbundled ESM) | Instant (no build) |
| Shared dependency negotiation | Built in, version-aware | Built in via plugin | Manual; you pin versions yourself |
| Runtime overhead | Container init + chunk fetch | Container init + chunk fetch | Plain ESM fetch, minimal |
| Best for | Existing Webpack estates, complex sharing | New apps, DX-first teams | Few rarely-changing remotes, simple deps |
| Main risk | Build config complexity | Build/dev asymmetry, plugin churn | No automatic dedupe of mismatched deps |
| Browser support | Universal (bundled) | Modern (esnext target) | Modern browsers; polyfill for old |
The practical reading: if you already run Webpack, the inertia argument for staying with native Module Federation is strong. If you are greenfield and value developer experience, Vite’s federation plugins have reached the point where they are a defensible default — verify your specific shared-dependency needs against the plugin first. And if your “federation” is really just loading a couple of independent, infrequently changing apps that share little, Import Maps for Native Module Loading gives you most of the benefit with none of the bundler machinery.
The cross-cutting risk in all three columns is dependency duplication. The full mechanics of how the share scope negotiates versions, and how to debug it when it goes wrong, live in Managing Shared Dependencies at Runtime.
One trap worth naming explicitly: the technology choice is reversible far more cheaply at the host than at the remotes. The host’s job — resolving a manifest URL and rendering an exposed module — is thin and bundler-agnostic. The remotes carry the real investment. So if you are unsure, bias toward whichever tool your remotes’ owning teams are most productive in, and keep the host deliberately minimal so it can wrap remotes from either bundler. A heavy host that bakes in product logic is the thing that locks you into a tool; a thin host is something you can rewrite in an afternoon.
Adoption Roadmap #
Treat the rollout as three gated phases. Each gate has explicit go/no-go criteria; do not advance until they are met.
Phase 1 — Pilot #
Pick one low-risk, clearly bounded remote — a settings panel, a marketing widget, a non-critical dashboard tile. Stand up the host shell, wire that single remote, and ship it to production behind a feature flag.
Go criteria to advance: the remote deploys without touching the host; React loads as a singleton (no duplicate-React warnings in the console); the host renders a graceful fallback when the remote’s remoteEntry.js is unreachable; and a stale-cache incident has been simulated and survived.
Phase 2 — Guardrails #
Before adding a second team, build the paved road. Establish the shared dependency policy as code, lock down remoteEntry.js caching, add contract testing so a remote cannot ship a breaking change to an exposed component undetected, and instrument remote load success/failure with telemetry.
Go criteria to advance: a deliberately introduced breaking change in a remote is caught by CI before merge; dashboards show remote load latency and error rate per remote; and a second team can onboard a remote using only documentation, without platform-team hand-holding.
Phase 3 — Scale #
Onboard the remaining domains, one team at a time. Each new remote follows the same template; the platform team’s job shifts from building to maintaining the paved road and tuning performance.
No-go signals — pause and reassess if: initial page load regresses past your LCP budget from too many parallel remote fetches; teams start exposing utilities and stores instead of components; or version negotiation warnings become routine background noise that everyone ignores. Any of these means the boundaries or the shared-dependency policy need rework before you add more remotes.
A subtle but important rule for the whole roadmap: every phase ships to real production behind a flag, never to a long-lived staging branch that diverges. Federation problems — stale manifests, version drift, CDN path mismatches — are overwhelmingly production-environment problems that staging hides. The flag is what lets you exercise the production path safely. If your first remote has not survived a real deploy, a real cache, and a real CDN, you have not actually validated federation; you have validated your dev server.
Resist the temptation to migrate everything at once. The incremental nature of federation is its biggest practical advantage over a big-bang rewrite: the existing monolith can be the host, and you carve out one remote at a time, leaving the rest untouched. A migration that strangles the monolith one route at a time is recoverable at every step. A migration that rebuilds everything behind a feature flag is a bet you cannot unwind cheaply if the boundaries turn out wrong.
Common Pitfalls #
Duplicate React instances — “Invalid hook call.” Root cause: a shared dependency is missing singleton: true, or a remote bundled its own copy because its requiredVersion could not be satisfied by the host. Resolution: mark React, React-DOM, the router, and any stateful library as singletons in both host and every remote, and align version ranges so the share scope can settle on one instance. Confirm in devtools that React appears once in the network tab.
Stale remoteEntry.js after a deploy. Root cause: the CDN caches the manifest, so consumers keep loading yesterday’s remote. Resolution: serve remoteEntry.js with Cache-Control: no-cache (or a short max-age with must-revalidate) while letting the hashed chunks it references be cached immutably. The manifest is the one file that must always be fresh.
publicPath mismatch breaking chunk loading. Root cause: a remote hardcodes a public path, then loads from a different origin and requests its chunks from the wrong place. Resolution: use output.publicPath: 'auto' so each remote infers its origin at runtime. This is the single most common cause of “works locally, 404s in production.”
Over-exposing internals. Root cause: teams expose API clients, hooks, or stores for convenience. Resolution: restrict exposes to finished presentation components and one narrow, versioned communication contract. Anything else recreates the build-time coupling federation was meant to eliminate.
Vite dev/build asymmetry. Root cause: the federation plugin produces remoteEntry.js only on build, so a remote run with vite dev is invisible to the host. Resolution: run remotes with vite build --watch (or build once and serve the output) while the host runs its dev server; document this loop so it does not surprise new contributors.
FAQ #
Should we choose Webpack 5 or Vite for Module Federation?
If you already run Webpack, native Module Federation is the path of least resistance and the most battle-tested option. If you are starting fresh and prioritize dev-server speed and developer experience, the Vite federation plugins are now mature enough to be a default — just validate your specific shared-dependency requirements against the plugin first, and plan around the build/dev asymmetry.
Can a Webpack host consume a Vite remote (and vice versa)?
Yes, with care. Both produce a compatible remoteEntry.js and both negotiate a shared scope, so cross-bundler federation works in practice. The friction is in matching shared-dependency configuration exactly — same singletons, same version ranges — so the two share scopes agree. Sharing singletons across mixed estates is enough of a topic to warrant its own deep dive in the runtime dependencies guides.
Does Module Federation replace a monorepo?
No — they solve different problems and pair well. A monorepo manages build-time code sharing, atomic refactors, and unified CI. Federation manages runtime composition and independent deployment. Many teams keep their code in a monorepo and use federation only for the deployment-independence boundary, getting both consistency and decoupled releases.
How do I keep a remote from crashing the whole page?
Treat every remote as a network call that can fail. Wrap each remote in an error boundary, render it through lazy loading with a fallback, set a timeout on the manifest fetch, and report failures to telemetry. A remote being down should degrade to a placeholder, never a white screen.
What does Module Federation do to performance?
Each remote adds at least one network request for its manifest plus its chunks, so a naive setup can push out LCP. Mitigate with HTTP/2 multiplexing, CDN edge caching, prefetching remote manifests during idle time, and lazy-loading non-critical remotes. Shared singletons help by loading common libraries once instead of per remote.
Are import maps a real alternative to Module Federation?
For a small number of independent apps that share few dependencies, yes — import maps load ES modules natively with no bundler runtime. The trade-off is that you manage version alignment yourself; there is no automatic share-scope negotiation. Past a handful of remotes with overlapping dependencies, federation’s automatic deduplication usually wins.