Module Federation as a Release Decoupling Tool for Webpack 5 Micro-Frontends
Webpack 5 Module Federation lets a host consume independently deployed remotes at runtime, negotiating shared singletons via shareScope to decouple release trains while keeping React and design system instances consistent.
16 Jul 2026, 08:17 UTC

Shipping a single Webpack build for an entire product means every team waits on the same release train. Module Federation in Webpack 5 changes the coupling point from build time to runtime: a host app can import modules from independently deployed remotes without rebuilding the host.
The useful takeaway is not that you can split code, but that you can negotiate shared singletons at runtime and keep deployment boundaries clean if you treat the share scope as a contract.
What actually happens at runtime
Webpack 5 ships ModuleFederationPlugin. A remote declares what it exposes and what it shares. A host declares remotes by name and URL.
When the host first imports a federated module, it fetches the remote’s remoteEntry.js manifest. That manifest lists exposed modules and the chunk URLs for them. The host then loads only the needed chunks. The remote can be rebuilt and redeployed to an immutable CDN path without touching the host, as long as the exposed module signatures stay compatible.
Shared dependencies are coordinated through a shareScope map. Each federated build lists packages in shared with a version range, singleton flag, and eager/lazy loading. At runtime Webpack picks the highest compatible version across host and remotes. If negotiation fails, a local copy is used, which is the failure mode you want to avoid.
Shared dependency strategy that holds up
For libraries that must be single instances, such as React, React DOM, and a design system, configure them as singletons with an explicit requiredVersion range.
// webpack.config.js for a remote, run with node as the build user
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
output: { publicPath: 'auto' },
plugins: [
new ModuleFederationPlugin({
name: 'header',
filename: 'remoteEntry.js',
exposes: { './Header': './src/Header' },
shared: {
react: { singleton: true, requiredVersion: '^18.2.0', eager: false },
'react-dom': { singleton: true, requiredVersion: '^18.2.0', eager: false },
'design-system': { singleton: true, requiredVersion: '^3.1.0', eager: false }
}
})
]
};
The host mirrors the remotes mapping and shares the same packages. The host does not need to know the implementation of Header, only the module name.
// host webpack.config.js
new ModuleFederationPlugin({
name: 'shell',
remotes: {
header: 'header@https://cdn.example.com/header/remoteEntry.js',
checkout: 'checkout@https://cdn.example.com/checkout/remoteEntry.js'
},
shared: {
react: { singleton: true, requiredVersion: '^18.2.0' },
'react-dom': { singleton: true, requiredVersion: '^18.2.0' },
'design-system': { singleton: true, requiredVersion: '^3.1.0' }
}
})
Placeholders like https://cdn.example.com must serve CORS headers Access-Control-Allow-Origin for the host origin, otherwise the browser blocks the manifest fetch. Build artifacts should be written to immutable paths with content hashes to avoid cache poisoning.
Worked example: shell with header and checkout remotes
Shell is the host. Header remote exposes ./Header, checkout remote exposes ./Cart. Both share React 18 and design-system 3.x as singletons.
At runtime the shell can do a dynamic import:
const Header = React.lazy(() => import('header/Header'));
The first render triggers a fetch of header/remoteEntry.js, then the chunk for Header. The shareScope negotiation runs once per remote per session and decides which React instance to use. If both remotes declare compatible ranges, only one React loads.
TypeScript needs explicit declarations because the host has no compile-time knowledge of remote exports. Use declare module 'header/Header' or generate .d.ts bundles alongside remotes. CSS from exposed components should be controlled by the host’s MiniCssExtractPlugin or style-loader; remotes should avoid emitting their own CSS chunks to prevent duplication.
Trade-offs and operational limits
Runtime negotiation adds a small runtime overhead, roughly a few kilobytes gzipped, plus a network round-trip for the first remote import. Debugging across origins requires source maps hosted for each remote and careful CORS configuration.
Breaking changes in a singleton, for example React 18 to 19, can cause multiple versions to load if the requiredVersion ranges do not overlap. That bloats bundles and can break hooks.
Hot module replacement across remotes is limited and often requires a full page reload. Circular dependencies between host and remotes are not detected at build time and fail at runtime. Server-side rendering with federation needs extra setup and is not zero-config.
To check the result after a build, inspect the generated remoteEntry.js for the correct exposes list and chunk URLs matching output.publicPath. In the browser DevTools Network tab, verify the manifest fetch followed by chunk loads on dynamic import. Changing a shared dependency within the declared range in one remote and rebuilding only that remote should be picked up by the host without a host rebuild. Introducing a version outside the range should result in two copies loading.
Keep requiredVersion pinned to the range you actually support, publish remotes to immutable CDN paths, and monitor for duplicate singleton loads in production error tracking.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.