Avoiding Bundle Duplication #
When several independently deployed micro-frontends each bundle their own copy of React, Vue, or a design system, the browser downloads the same megabytes over and over. Worse than the wasted bandwidth is the correctness failure: two React runtimes in one page produce the infamous “Invalid Hook Call” error, broken context, and components that silently lose their state. This is the single most common way a Module Federation setup goes from “working in the demo” to “unshippable in production.”
This guide sits under Core Micro-Frontend Architecture Tradeoffs and covers the configuration, runtime wiring, and validation needed to share dependencies once and only once. For the deeper sub-topics, read measuring the bundle size impact of shared dependencies to quantify the payoff, and configuring shared singletons to deduplicate React for the exact framework-level settings.
How duplication actually arises #
Each remote is built in isolation by its own pipeline. When the bundler walks import React from 'react', it has no knowledge that some other remote, built last week in a different repository, also imported React. So it does the only safe thing it can: it copies React into that remote’s output. Repeat across five teams and you have five independent React copies, each with its own module registry, its own useState dispatcher, and its own bundle weight.
Sharing reverses that default. Instead of resolving react to a local module, the bundler resolves it to a lookup in a runtime share scope — a window-level registry that every container reads from and writes to. The first container to need React registers its version into the scope; later containers find a satisfying version already there and borrow it instead of executing their own copy. That single negotiation is what turns five bundled copies into one shared instance.
Two terms anchor everything below. A singleton share forces exactly one instance into the scope regardless of how many versions are offered — mandatory for anything that holds module-level state. A non-singleton share lets multiple versions coexist, each container getting the highest version that satisfies its own range. Frameworks, stores, and routers must be singletons; stateless utilities usually should not be.
The diagram below contrasts the two outcomes: every remote carrying its own vendor chunk versus a single shared scope that all remotes borrow from.
What actually breaks #
Duplication is not a single bug — it shows up as three distinct failure modes, and each one needs a different fix.
- Payload bloat. Five remotes that each bundle React 18 ship roughly 5× the framework code. On a slow connection that is hundreds of extra kilobytes parsed and executed before anything interactive renders.
- Runtime identity conflicts. React tracks hooks against a module-level dispatcher. Two React instances mean two dispatchers, so a component rendered by remote A using React from remote B throws
Invalid hook callor corrupts its fiber tree. - Silent state divergence. Singletons like a Redux store, a router, or an auth context only work if every consumer imports the same module instance. A second copy gives you a second store that never syncs.
The third failure is the cruelest because it has no error message. useContext returns undefined not because the provider is missing but because the consumer is reading a different React’s context object. The same trap catches react-router (two history objects, so navigation in one remote never updates the URL the other reads) and any auth library that caches a token in a module-level variable.
Key objectives #
- Identify which packages must be strict singletons (frameworks, state stores, routers) versus which can safely run as multiple instances.
- Declare each shared package explicitly with a pinned semver range, not via blanket auto-discovery.
- Wire the host and remotes so the shared scope is initialized before any remote module is consumed.
- Validate deduplication with both static bundle analysis and a runtime check of the live share scope.
- Make dependency upgrades a coordinated, reversible operation rather than a per-team free-for-all.
Setup and config #
The contract for sharing lives in ModuleFederationPlugin. The shared block decides, per package, whether the bundler reuses an instance from the scope or falls back to a bundled copy.
Decide what belongs in the global graph before you start — application-specific utilities should not be promoted into shared scope just because two remotes happen to import them today. Settle ownership and the upgrade cadence alongside your versioning strategy for remote apps, so the requiredVersion ranges below reflect a real policy.
A few rules drive every field in the config:
- List packages explicitly. Naming each one gives you per-package control and makes the share contract reviewable in a pull request.
singleton: truefor anything stateful at the module level. This forces a single in-memory instance regardless of how many remotes request it. Frameworks, stores, and routers qualify.- Pin with
requiredVersion+strictVersion: true. The bundler validates the resolved version against each remote’s requirement; on mismatch it fails loudly instead of quietly loading a second copy. - Keep
eager: false(the default). Eager sharing pushes the dependency into the host’s initial chunk, which both bloats first load and undermines runtime negotiation.
Annotated host config #
In 2026 the actively maintained path is Module Federation 2.0 via @module-federation/enhanced, which ships a richer runtime, manifest support, and first-class type sharing. The shared schema is identical to the legacy webpack.container.ModuleFederationPlugin, so the fields below transfer directly.
// webpack.config.js — host
const { ModuleFederationPlugin } = require('@module-federation/enhanced/webpack');
const deps = require('./package.json').dependencies;
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'hostApp',
filename: 'remoteEntry.js',
remotes: {
checkout: 'checkout@https://cdn.example.com/checkout/remoteEntry.js',
},
shared: {
// Strict singleton: one React runtime for the whole page.
react: {
singleton: true, // exactly one instance, ever
requiredVersion: deps.react, // e.g. "^18.2.0", read from package.json
strictVersion: true, // throw on an unsatisfiable range
eager: false, // negotiate at runtime, not at first load
},
'react-dom': {
singleton: true,
requiredVersion: deps['react-dom'],
strictVersion: true,
eager: false,
},
// State store must be a singleton too — two stores never sync.
'@reduxjs/toolkit': {
singleton: true,
requiredVersion: deps['@reduxjs/toolkit'],
},
// Router holds module-level history; also a singleton.
'react-router-dom': {
singleton: true,
requiredVersion: deps['react-router-dom'],
},
// UI components are safe to run as multiple instances.
'@shared/ui-kit': {
requiredVersion: deps['@shared/ui-kit'],
singleton: false,
},
},
}),
],
};
Read requiredVersion from package.json rather than hard-coding it; the version you ship and the version you advertise then never drift apart. Each remote declares the same shared block (frameworks identical, app-specific entries as needed). At runtime the first remote to load a singleton populates the scope, and every later consumer reuses that exact instance.
A field worth knowing is version, which you can set explicitly to override what the bundler infers. It matters when a package’s package.json version is unreliable (monorepo workspaces, patched forks) — set version to advertise the truth so negotiation is not fooled.
The Vite equivalent #
@module-federation/vite (the successor to @originjs/vite-plugin-federation) mirrors the same intent and, since the 2.0 line, accepts the object form for shared, closing most of the historical gap with Webpack.
// vite.config.ts — remote
import { defineConfig } from 'vite';
import { federation } from '@module-federation/vite';
export default defineConfig({
plugins: [
federation({
name: 'checkout',
filename: 'remoteEntry.js',
exposes: { './Checkout': './src/Checkout.tsx' },
shared: {
react: { singleton: true, requiredVersion: '^18.2.0' },
'react-dom': { singleton: true, requiredVersion: '^18.2.0' },
'@reduxjs/toolkit': { singleton: true },
},
}),
],
build: { target: 'esnext' }, // top-level await is required for the runtime
});
Vite’s plugin historically lacked build-time strictVersion enforcement, which pushed version policing into CI; the 2.0 runtime narrows that, but a CI version check is still the dependable backstop. The runtime negotiation rules for sharing instances across both bundlers are covered in managing shared dependencies at runtime, and a side-by-side of the two plugins lives in the Vite federation parity guide.
Eager vs lazy, in one paragraph #
Lazy sharing (the default) means the dependency is fetched asynchronously and resolved through the scope at the moment it is first needed — this is what enables negotiation. Eager sharing inlines the dependency into the entry chunk so it is available synchronously. You only need eager when the host’s own root module imports the shared package before any container has had a chance to init the scope — for example a top-level import App from './App' in the host entry that pulls React synchronously. The clean fix is not eager sharing but a dynamic boundary: a tiny import('./bootstrap') in the entry that defers the synchronous React import past scope initialization.
Integration #
Configuration alone does nothing until the runtime wiring activates the scope in the right order. The sequence is non-negotiable: initialize sharing, init the container against that scope, then request modules.
// remoteLoader.js
async function loadRemote(remoteName, modulePath) {
// 1. Populate the host's share scope with its own dependencies.
await __webpack_init_sharing__('default');
// 2. Grab the container the remoteEntry script registered on window.
const container = window[remoteName];
// 3. Let the remote negotiate which deps to borrow vs. bundle.
await container.init(__webpack_share_scopes__.default);
// 4. Only now is it safe to fetch an exposed module.
const factory = await container.get(modulePath);
return factory();
}
If you call container.get() before init(), the remote has no scope to negotiate against and falls back to its own bundled copies — the exact duplication you are trying to avoid.
With Module Federation 2.0 you rarely write this by hand. The @module-federation/enhanced/runtime package exposes a typed loader that performs the handshake and adds retry, fallback, and manifest resolution for you:
// loadCheckout.ts — Module Federation 2.0 runtime API
import { loadRemote, init } from '@module-federation/enhanced/runtime';
init({
name: 'hostApp',
remotes: [
{ 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' },
},
});
export async function getCheckout() {
const mod = await loadRemote<typeof import('checkout/Checkout')>('checkout/Checkout');
return mod!.default;
}
The plain import('checkout/Checkout') syntax also does the handshake for you when the federation plugin rewrites it, but understanding the manual path is what lets you debug it when it misbehaves. Wrap consumption in a boundary so a failed negotiation degrades instead of taking down the host:
async function safeLoadCheckout() {
try {
return await loadRemote('checkout', './Checkout');
} catch (err) {
console.warn('Shared scope negotiation failed; loading isolated fallback', err);
return loadIsolatedFallback();
}
}
Edge cases #
- Race on first paint. If two remotes initialize concurrently, both may try to be the singleton provider. The runtime resolves this by version, but eager-loading one of them changes the winner. Keep singletons lazy so the highest satisfying version wins deterministically.
- Version just outside the range. A remote pinned to
^18.3.0against a host on18.2.0will, understrictVersion, throw rather than load. Widen the host range to the lowest common denominator across remotes, or upgrade the host first. - Ranges that never negotiate. A remote requiring
^19.0.0and a host offering^18.2.0have no overlapping version. Withsingleton: truethe runtime logs a warning and uses whatever single instance exists, which can crash; withstrictVersion: trueit throws outright. Neither is recoverable at runtime — the only fix is aligning the ranges in source. - Mixed majors in flight. During a React 17 → 18 migration you genuinely have two incompatible majors. Forcing both through one singleton breaks one of them. Expose each major under a separate
shareScopekey from a transitional host, or run the lagging remote in an isolated scope until it is migrated. Step-by-step conflict resolution is covered in resolving version conflicts in shared React libraries. - Missing-from-scope fallback. When a remote requests a package the host never declared, the bundler loads the remote’s own copy in isolation. That is correct behavior — just make sure your fallback UI tolerates the isolated instance.
- Intentional multi-instance. Legacy charting or date-picker libraries that hold no cross-app state are fine as
singleton: false; forcing them to a singleton can surface incompatibilities for no benefit.
To diagnose a mismatch before it bites, inspect the live scope:
// inspectScope.js — run after __webpack_init_sharing__ completes
import semver from 'semver';
function inspectSharedVersion(pkgName) {
const scope = __webpack_share_scopes__.default;
const entry = scope[pkgName];
if (!entry) return null;
const versions = Object.keys(entry).filter(v => semver.valid(v));
return versions.sort(semver.rcompare)[0]; // highest registered version
}
const react = inspectSharedVersion('react');
if (react && !semver.satisfies(react, '^18.0.0')) {
console.warn(`Host React ${react} does not satisfy remote requirement ^18.0.0`);
}
Testing and validation #
Catch duplication in CI, not in a production incident. Use both a static and a runtime check — each catches what the other misses.
- Bundle analysis. Run
webpack-bundle-analyzer(orrollup-plugin-visualizerfor Vite) and assert that React lives in exactly one chunk, not inside multiple remote entries. The Module Federation runtime stats plugin can also emit a manifest you can diff between builds. - Runtime scope assertion. A lightweight Playwright check reads the live share scope and fails if a singleton appears more than once.
- Size budgets. Fail the build when a shared chunk grows beyond its budget; a sudden jump usually means something got re-bundled. Webpack’s
performance.maxAssetSizeor asize-limitconfig both work. - Throttled fallback test. Throttle the network in dev tools, kill a remote, and confirm the fallback renders without crashing the host.
// dedupe.spec.js — Playwright runtime assertion
import { test, expect } from '@playwright/test';
test('react resolves to a single shared instance', async ({ page }) => {
await page.goto('/');
const reactVersions = await page.evaluate(() => {
const scope = window.__webpack_share_scopes__?.default ?? {};
return Object.keys(scope.react ?? {});
});
// Exactly one version key means one negotiated React instance.
expect(reactVersions).toHaveLength(1);
});
A stricter unit-level guard asserts referential identity directly — that the React the host imports is the same object a loaded remote imports. This is the most direct proof there is exactly one runtime:
// identity.spec.ts — single-instance assertion
import { test, expect } from 'vitest';
import hostReact from 'react';
import { getCheckout } from '../src/loadCheckout';
test('remote and host resolve the same React object', async () => {
await getCheckout();
// The remote re-exports the React it actually resolved at runtime.
const remoteReact = (window as any).__CHECKOUT_REACT__;
expect(remoteReact).toBe(hostReact); // identity, not just equal versions
});
The deeper metrics — how many kilobytes deduplication actually saves and how that maps to Core Web Vitals — are covered in measuring bundle size impact of shared dependencies.
Deployment #
Shared dependencies couple deployments whether you like it or not, so the rollout has to acknowledge that coupling.
- Cache the right things differently. Serve hashed shared chunks with
Cache-Control: public, max-age=31536000, immutable, but serveremoteEntry.jswith a short TTL orno-cacheso the manifest always points at the current build. - Upgrade behind a flag. Never bump the host’s shared framework version without first confirming every remote satisfies the new range. Roll it out as a canary and watch hook-error rates.
- Pre-deploy smoke check. Run the runtime scope assertion against the staged host plus all remotes before shifting traffic.
- Automated rollback. Trip a rollback when version-negotiation failures cross a threshold (for example, >1% of sessions in a 5-minute window).
- Version matrix in source control. Track the required
sharedversions per remote and block any pipeline that violates it. The matrix is the enforcement arm of your versioning strategy for remote apps.
Because the host’s shared version is the lowest common denominator, the safe upgrade order is fixed: raise the host’s requiredVersion only after every remote already ships a version that satisfies the new range, then upgrade remotes opportunistically afterward. Inverting that order — upgrading a remote first — leaves the remote demanding a version the host’s scope cannot provide, which is exactly the unsatisfiable-range failure from the edge cases section.
Common pitfalls #
| Issue | Root cause & resolution |
|---|---|
| “Invalid hook call” / duplicate React | A second React instance loaded because singleton: true was missing or a version fell outside the range. Set singleton: true and strictVersion: true identically in every host and remote config. |
| Eager loading defeats sharing | eager: true forced the dependency into the host’s initial chunk, bypassing runtime negotiation and bloating first load. Keep eager: false and defer the host’s synchronous import behind a dynamic bootstrap boundary. |
| Silent fallback to a second copy | An overly broad range (*, >=1.0.0) let an incompatible version resolve, or strictVersion was off. Use caret ranges read from package.json and enable strictVersion: true. |
| Remote loads its own deps | container.get() ran before __webpack_init_sharing__() / container.init(). Always await both, or use the 2.0 loadRemote runtime which orders the handshake for you. |
| Two Redux stores that never sync | The store package was shared as singleton: false, so each remote got its own instance. Mark module-level stateful packages (@reduxjs/toolkit, react-router) as singletons. |
useContext returns undefined despite a provider |
Provider and consumer resolved different React instances, so they read different context objects. Confirm a single React via the identity assertion, not just matching version strings. |
| Ranges that never overlap | Host offers ^18 while a remote demands ^19. No runtime fix exists — align the semver ranges in source and migrate majors via a transitional shareScope. |
FAQ #
How do I stop duplicate React instances in Module Federation?
Mark react and react-dom as singleton: true with strictVersion: true in both the host and every remote, reading requiredVersion from each project’s package.json. The first remote to load populates the shared scope and all others reuse that instance.
Should every dependency be a singleton?
No. Only packages that hold module-level state need to be singletons: frameworks, state stores, and routers. Utility libraries, most UI components, and CSS-in-JS engines are usually safe as multiple instances, and forcing them into a singleton can introduce avoidable version conflicts.
What happens when a remote needs a version the host doesn’t have?
With strictVersion: true the runtime throws during negotiation; without it, the remote loads its own bundled copy in isolation. If the ranges have no overlap at all, there is no recoverable runtime outcome — you must align the ranges in source and, for incompatible majors, isolate them in separate shareScope keys.
How do I prove deduplication is actually working?
Run a bundle analyzer in CI to confirm the package lives in one chunk, and add a runtime assertion that reads __webpack_share_scopes__.default and fails if a singleton has more than one registered version. For the strongest guarantee, assert referential identity — that the host’s react object and a remote’s react object are the same reference, not merely the same version string.