Webpack Module Federation: Runtime Coupling Trade‑offs for Micro‑Frontends
Webpack Module Federation lets teams deploy micro‑frontends independently by sharing code at runtime. This post covers host/remote config, shared dependency strategies, a worked example, and the trade‑offs so you can decide if the freedom is worth the cost.
11 Mar 2026, 17:03 UTC

The problem: independent deployments without npm publishing
Teams want to deploy frontend pieces on different schedules. The traditional answer is publishing versioned npm packages, but that adds release overhead, version coordination, and build‑time coupling. Webpack 5’s Module Federation offers a different contract: share code at runtime by having a host application fetch a remoteEntry.js manifest from each remote at startup, then negotiate shared dependencies (React, design tokens, utilities) so only one copy loads in the browser.
The useful takeaway: Module Federation trades build‑time guarantees for deployment independence. You gain the ability to push a remote update without rebuilding the host, but you accept runtime version negotiation, opaque dependency graphs, and debugging that crosses network boundaries. Choose it when teams truly deploy on different cadences and can tolerate the operational complexity.
Host and remote: the two roles
A host consumes federated modules. A remote exposes them. Both are independent webpack builds. The remote declares an exposes map (what it offers) and a shared map (what it expects to share). The host declares a remotes map (where to find each remote’s remoteEntry.js) and its own shared map.
At runtime, when the host imports 'app1/Header', webpack fetches remoteEntry.js from the remote’s URL, reads the exposed module map, resolves shared dependencies against already‑loaded singletons, and downloads only the missing chunks. The remote’s code executes inside the host’s page, sharing the same React instance, context providers, and global state — if configuration aligns.
Shared dependency strategies: singleton, eager, strictVersion
The shared configuration is where most production issues originate. Three flags control the negotiation:
singleton: true— only one instance of this package loads across host and all remotes. Required for React, state libraries, CSS‑in‑JS runtimes.eager: true— load the shared dependency at host startup, not lazily when the remote is first imported. Increases initial bundle size but avoids a waterfall request.strictVersion: true— fail loudly if host and remote declare incompatible versions (per semver). Use in CI; risky in production without a graceful fallback.
A typical React setup:
// webpack.config.js (both host and remote)
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'host', // or 'remote'
shared: {
react: { singleton: true, eager: true, requiredVersion: '^18.2.0' },
'react-dom': { singleton: true, eager: true, requiredVersion: '^18.2.0' }
}
})
]
};
Omit eager: true for remotes that load on demand (e.g., a settings panel) to keep the initial payload small. The trade‑off: the first import of that remote triggers an extra network round‑trip for the shared chunk.
Worked example: host on port 3000 consumes Header from remote on port 3001
This configuration assumes webpack 5.70+ and @module-federation/webpack (or built‑in ModuleFederationPlugin). Run each in its own terminal with npm run dev (requires Node 16+ and write access to the project directories).
Remote config (port 3001)
// remote/webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
entry: './src/index',
output: { publicPath: 'auto' },
devServer: { port: 3001 },
plugins: [
new ModuleFederationPlugin({
name: 'app1',
filename: 'remoteEntry.js',
exposes: {
'./Header': './src/Header'
},
shared: {
react: { singleton: true, eager: true, requiredVersion: '^18.2.0' },
'react-dom': { singleton: true, eager: true, requiredVersion: '^18.2.0' }
}
})
]
};
Host config (port 3000)
// host/webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
entry: './src/index',
output: { publicPath: 'auto' },
devServer: { port: 3000 },
plugins: [
new ModuleFederationPlugin({
name: 'host',
remotes: {
app1: 'app1@http://localhost:3001/remoteEntry.js'
},
shared: {
react: { singleton: true, eager: true, requiredVersion: '^18.2.0' },
'react-dom': { singleton: true, eager: true, requiredVersion: '^18.2.0' }
}
})
]
};
Consuming the remote in host code
// host/src/App.tsx
import React, { Suspense, lazy } from 'react';
// Dynamic import recommended for lazy‑loaded remotes
const Header = lazy(() => import('app1/Header'));
export default function App() {
return (
<div>
<Suspense fallback="Loading header…">
<Header />
</Suspense>
</div>
);
}
TypeScript users must declare the remote module or install @module-federation/typescript declarations; otherwise Cannot find module 'app1/Header' errors appear at compile time.
Trade‑offs and limitations you will hit
- No dead‑code elimination across boundaries. The host cannot tree‑shake unused exports from the remote because the remote is opaque at host build time. Every exposed module ships its full dependency subtree (minus shared singletons).
- Version mismatches surface as cryptic runtime errors. If the remote upgrades React to 19 while the host stays on 18,
strictVersion: truethrows a console error during federation initialization. Without it, you get two React instances — hooks break, context providers disconnect, and React DevTools shows duplicate roots. - CSS and assets are not shared automatically. Add
shared: { './styles.css': { eager: true } }or link a common stylesheet from the host HTML. Scoped CSS modules work but require identical class names across builds. - HMR across federation boundaries is limited. Changing a remote component often requires a full host reload. The remote’s dev server can HMR itself, but the host’s module graph doesn’t automatically re‑pull the updated
remoteEntry.js. - Debugging spans network tabs. Stack traces cross from host bundle to remote chunk. Source maps must be served from both origins; configure
devtool: 'source-map'in both builds and ensure CORS headers allow the host to fetch remote maps.
When to choose alternatives
npm packages with semantic versioning give you build‑time guarantees, full tree‑shaking, and standard tooling. The cost: every remote change requires a publish and a host version bump. Choose this when teams can synchronize releases or when the shared surface is stable.
Import maps (native ES modules) let you map bare specifiers to URLs at runtime without webpack coupling. They’re lighter weight but lack Federation’s shared dependency negotiation and require modern browsers (or a polyfill). Choose this for simpler micro‑frontend setups where you control the deployment infrastructure and don’t need singleton enforcement.
How to verify your setup before shipping
- Run both dev servers. Open the host at
http://localhost:3000. Confirm the remote Header renders. - Open DevTools Network tab. Filter for
remoteEntry.js. Verify it loads, then only missing shared chunks (if any) load afterward. - Open React DevTools. Confirm a single React root and one
react/react-dominstance. - Build production:
webpack --mode=productionin each project. Runnpx webpack-bundle-analyzeron the host’s output. Verify no duplicate React code appears in the host bundle. - Test a version mismatch: upgrade React in the remote only, set
strictVersion: true, reload host. Observe the federation initialization error. RemovestrictVersionand observe the duplicate React warning in console.
Closing: decide with eyes open
Module Federation is a powerful tool for a specific organizational shape: multiple teams, independent deployment pipelines, and a willingness to invest in runtime observability. If your team structure doesn’t match, the complexity rarely pays off. Start with a two‑repo proof of concept using the configuration above, run the verification steps, and measure the operational overhead before committing to Federation in production.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.