Versioning Shared Dependencies Across Module Federation Remotes
Module Federation lets a host and its remotes share a dependency instead of each bundling its own copy — but only if the shared config agrees on which version wins and whether the library must be a singleton. Get it wrong and you ship two Reacts, or the host silently loads an incompatible remote version. This guide configures shared-dependency versioning correctly. It deepens module federation and micro-frontend architectures; the parent overview is Core Concepts of Modern Bundling.
The problem exists because federation defers dependency resolution to runtime. In a conventional single-bundle app, the bundler resolves every import 'react' at build time against one node_modules tree, deduplicates it, and emits exactly one copy — the version conflict is settled before a byte ships. Federation deliberately breaks that guarantee: the host and each remote are built independently, often in separate repositories, on separate CI pipelines, at separate times. Each build produces its own bundle with its own idea of what react resolves to. The shared configuration is the contract that lets these independently-built artifacts agree, in the browser, on a single copy to load — but it is a negotiation, not a guarantee, and a negotiation only succeeds when both parties declare the same terms.
What breaks without a correct contract falls into two buckets. The loud failure is duplication: two React instances load, each with its own module-scoped dispatcher, and the first hook call from a component rendered by the “wrong” copy throws Invalid hook call. The quiet failure is worse — a remote built against a version the host does not actually provide is loaded anyway, and you get subtle runtime breakage (a context that reads as undefined, a store whose subscribers never fire) that no build step warned you about. Both failures are runtime-only. Nothing in npm run build catches them, which is why the debugging workflow below leans on the browser console and the live share scope rather than on the compiler.
Where this sits in the pipeline: shared is consumed by the federation plugin during each independent build to emit share metadata alongside the code, and then again by the federation runtime shim in the browser to reconcile that metadata across all loaded containers. Understanding both halves — what the plugin bakes in at build time and what the runtime negotiates at load time — is what separates a config you can debug from one you copy and pray over.
singleton forces every consumer onto that single instance.Prerequisites & reproducible setup
# Vite 5.4.x + @originjs/vite-plugin-federation, or webpack 5 ModuleFederationPlugin
npm install --save-dev @originjs/vite-plugin-federation
# A host app and at least one remote, both depending on react/react-dom.
Both host and remote must list the shared library at compatible versions. React and React DOM are the canonical singletons — two copies break hooks with “invalid hook call” because each copy has its own dispatcher.
The reproducible minimum is two apps: a host that consumes a remote, and a remote that exposes at least one component using React hooks. The hook is not incidental — a remote that exposes only static markup will never exercise the dispatcher, so it can duplicate React without ever throwing, which masks the bug until someone adds a useState. When building a reproduction to test your shared config, always expose a component that calls at least one hook and renders it from the host. That is the smallest surface that actually proves the singleton negotiation worked rather than merely compiled.
One detail trips up first-time setups: the versions the plugin records are read from each app’s own installed node_modules, not from what you wrote in requiredVersion. If the host has react@18.3.1 installed and the remote has react@18.2.0, those are the provided versions that enter the negotiation; requiredVersion only sets the range each side will accept. Keeping the two apps’ lockfiles roughly aligned removes an entire class of “why is it loading two copies” confusion before it starts.
How version resolution works under the hood
Every container — the host and each remote is a “container” in federation terms — carries a shared scope: a runtime registry keyed by package name, where each key maps to the set of versions currently available for that package, tagged with metadata (the semver version, whether the provider marked it a singleton, whether it was loaded eagerly, and a factory function that returns the module). The first container to boot seeds this scope; every container loaded afterward merges its own shared entries into the same object rather than creating a private one. This is why the negotiation is global and not per-remote: there is one shared scope for the whole page, and every consumer reads from it.
When a module inside any container calls import 'react', that import is not a static reference to a bundled file. The plugin has rewritten it into a call through the runtime that says, in effect, “give me a react satisfying ^18.2.0.” The runtime looks in the shared scope, collects every registered react version, filters to the ones that satisfy the requesting range, and picks the highest satisfying version. That chosen factory is invoked once, its result cached, and every subsequent request for a compatible react returns the same cached instance. The “highest compatible” rule is the entire resolution algorithm in one sentence, and it explains most surprises: if a remote you did not expect provides react@18.3.5 and everyone’s range accepts it, that is the copy the whole page runs on, not the host’s 18.2.0.
The singleton flag changes the failure mode of that filter, not the selection itself. Without singleton, if the requested range cannot be satisfied by anything already in the scope, the runtime falls back to the requester’s own bundled copy — correct for a stateless utility, catastrophic for React. With singleton: true, the runtime is instructed that only one instance may ever exist, so instead of falling back to a private copy it uses the single registered version even when it does not strictly satisfy the range, and emits an Unsatisfied version warning rather than duplicating. strictVersion: true upgrades that warning into a hard error, which is the behavior you want in CI. In short: singleton decides how many instances, requiredVersion decides which versions are acceptable, and strictVersion decides what happens when those two goals conflict.
Eager versus lazy sharing is the last mechanism worth internalizing. By default shared modules are loaded lazily: the runtime only materializes a shared dependency when something first imports it, which keeps the initial chunk small but means the negotiation happens mid-navigation. Marking a shared module eager: true loads it into the initial container synchronously, which you sometimes need for a dependency the host references at module-evaluation time (before any remote is fetched) — but eager sharing pulls that copy into the host’s main bundle, so overusing it defeats the point of sharing. Reach for eager only on the handful of dependencies the host cannot defer.
Diagnosis workflow
-
Check for two copies at runtime. Open the running page’s console and inspect the share scope:
Object.keys(__webpack_share_scopes__.default)on webpack, orwindow.__federation_shared__with the Vite plugin, lists every shared package and — one level deeper — the set of versions registered for each. The symptom you are hunting for is a package whose entry holds more than one version, or the same package registered under two different scopes. Root cause is always the same: at least one consumer’srequiredVersiondid not overlap the provided version, so the runtime fell back to a private copy. Confirm the fix by re-reading the scope after a hard reload and seeing a single version listed. Do this in the deployed build, not just dev — Vite’s dev server pre-bundles dependencies differently and can hide a duplication that only appears in the production federation output. -
Decide which need
singleton. Walk the shared list and split it by whether the library holds process-global state. React and React DOM own a module-scoped dispatcher; a router owns the history and the active-route context; a store (Redux, Zustand, a signals atom) owns the state tree that components subscribe to. Any of these loaded twice gives you two disconnected worlds — components rendered by remote A subscribe to a store the host never updates — and the failure is silent because nothing throws, the UI just goes stale. Mark every such librarysingleton: true. Pure, stateless utilities (date-fns,lodash-es,clsx) hold no cross-boundary state, so a second copy is merely wasted bytes, not a correctness bug; leaving them non-singleton lets independently-versioned remotes upgrade on their own schedule. When in doubt, ask “does anything read shared state from this module?” — if yes, singleton. -
Set the version contract.
requiredVersiondeclares the semver range a consumer will accept, and it defaults to the version installed in that app’snode_modulesif you omit it. The contract holds only when the provided version (what some container actually registers) falls inside every consumer’s required range. A mismatch — host provides18.3, remote requires^17— produces a runtimeUnsatisfied versionwarning and, absent a singleton, a second copy. Set the range as wide as is genuinely safe (^18.2.0, not18.2.0) so a patch bump on one side does not shatter the negotiation, but no wider than the library’s actual compatibility, because an over-broad range is how a remote silently accepts a major it was never tested against.
singleton; pure utilities do not.Annotated solution config
// vite.config.ts (host) — @originjs/vite-plugin-federation, Vite 5.4.x
import { defineConfig } from 'vite';
import federation from '@originjs/vite-plugin-federation';
export default defineConfig({
plugins: [
federation({
name: 'host',
remotes: { catalog: 'https://catalog.example.com/assets/remoteEntry.js' },
shared: {
react: {
singleton: true, // exactly one React instance
requiredVersion: '^18.2.0', // what this app accepts
},
'react-dom': { singleton: true, requiredVersion: '^18.2.0' },
// A utility that need not be a singleton — two versions are tolerable.
'date-fns': { requiredVersion: '^3.0.0' },
},
}),
],
});
// The remote declares the SAME shared contract so negotiation succeeds.
shared: {
react: { singleton: true, requiredVersion: '^18.2.0' },
'react-dom': { singleton: true, requiredVersion: '^18.2.0' },
}
Two lines in the host config carry the whole design. singleton: true on react and react-dom is non-negotiable: these are the libraries whose duplication throws, so the runtime must be told exactly one instance may exist. requiredVersion: '^18.2.0' is deliberately a caret range, not a pin — it accepts any 18.x at or above 18.2.0, which lets the host tolerate a remote that happens to have installed 18.3.1 without renegotiating. The date-fns entry demonstrates the opposite decision: no singleton, because two date formatters coexisting harm nothing but bundle size, and forcing a singleton there would needlessly couple every remote’s release cadence to the host’s.
Note what is not here. The host does not pin an exact version, does not set eager, and does not repeat react-dom’s range on a separate line for style — it inherits the same contract because it is the same negotiation. The one asymmetry between host and remote configs is intent, not syntax: the host additionally lists remotes to declare what it consumes, while the remote lists exposes (omitted above for brevity) to declare what it publishes. The shared block itself is identical on both sides, and it must be — a shared entry that differs between the two apps is the single most common cause of a duplicate that “should” have been shared.
For a store, the contract is the same shape but the failure it prevents is quieter, so it is worth seeing on its own:
// vite.config.ts fragment — sharing a Zustand store as a singleton.
// @originjs/vite-plugin-federation, Vite 5.4.x
shared: {
// Two copies would give host and remote independent state trees;
// singleton keeps subscribers on ONE store instance.
zustand: { singleton: true, requiredVersion: '^4.5.0' },
// Peer state libraries that also assume one instance:
react: { singleton: true, requiredVersion: '^18.2.0' },
'react-dom': { singleton: true, requiredVersion: '^18.2.0' },
}
Unlike React, a duplicated store never throws. The host dispatches an action, its subscribers update, and the remote’s components — wired to a second store instance — simply never re-render. There is no console error to grep for; the only signal is a UI that ignores a state change. That silence is exactly why state containers belong on the singleton list next to React, and why the diagnosis workflow above starts by reading the share scope rather than waiting for an exception.
singleton controls instance count; requiredVersion controls which version satisfies both sides.Verification
// In the running host console — confirm one React instance is shared.
// The share scope should list react with a single loaded version.
console.log(window.__federation_shared__ ?? __webpack_share_scopes__?.default);
// A React app renders without "Invalid hook call" — the singleton proof.
With singleton: true and overlapping requiredVersion ranges, the share scope shows one React version and the app renders without the “Invalid hook call” error that a second copy triggers. A mismatch instead logs Unsatisfied version and falls back to loading a separate copy.
Read the verification as two independent proofs, because either can pass while the other fails. The share-scope log proves how many copies loaded: one version listed means the negotiation collapsed to a single instance. The absence of Invalid hook call proves the right copy is driving the dispatcher. You can construct cases where the scope shows one version but a stale cached bundle still ships a second — which is why the render test matters as a separate check. Run both in the production build served over the network, not the dev server, and run them after a cache-busting reload; a service worker or a CDN holding an old remoteEntry.js is a frequent reason the console disagrees with the config you just deployed. If the log is clean and the app renders, the contract is satisfied; if the log shows two versions, jump back to step 3 and widen or align the ranges.
Gotchas & edge cases
singleton is the one that fails loudly — the invalid hook call is a duplicate-instance symptom.-
Non-overlapping ranges. Symptom: the share scope lists two versions of the same package and, for a stateless lib, the app works but ships double. Root cause: the provided version sits outside a consumer’s required range — host provides
18.3, a remote requires^17, and18.3does not satisfy^17. Fix: align the ranges so a single version satisfies every consumer, usually by widening the stricter side to a caret range that covers the provided major. Confirm: the scope collapses to one version after reload. -
Forgetting
singletonon React. Symptom:Invalid hook call. Hooks can only be called inside the body of a function component, thrown the first time a hook runs in a remote-rendered component. Root cause: two React copies loaded, each with its own dispatcher, and the component’s hooks reach the copy that is not currently rendering. Fix: mark bothreactandreact-domsingleton: trueon host and every remote — the flag is what tells the runtime to reuse one instance instead of falling back to a private copy. Confirm: the render test passes and the scope shows a singlereact. -
Remote built against a newer major. Symptom:
Unsatisfied version 18.x.x from host does not satisfy requiredVersion ^19.0.0. Root cause: the remote was built and tested against React 19 but the host only provides 18; no compatible version exists to negotiate, and with a singleton the remote is forced onto an API it does not expect. Fix: upgrade the host to the major the remote needs, or pin the remote back to the host’s major — you cannot bridge a real major gap with config, because the incompatibility is in the API, not the version string. Confirm: both apps resolve to the same major. -
strictVersion. Symptom: everything looks fine in the console — only a warning — yet a component misbehaves in a way that tracks a version skew. Root cause: a singleton withoutstrictVersionwill accept a technically-incompatible version (a wrong major) and merely log rather than fail, so the mismatch reaches production. Fix: setstrictVersion: trueon singletons whose contract you actually depend on, turning the warning into a load-time throw. Confirm: an intentional mismatch now fails the page immediately instead of degrading quietly — which is precisely what you want a mismatch to do before users see it.
CI integration
The reason these bugs escape review is that they are invisible to the compiler; a green npm run build on both the host and the remote tells you nothing about whether their negotiated versions will agree at runtime. Close that gap in CI with two cheap checks. First, after building each app, assert that the versions the plugin recorded in the share metadata fall inside the ranges the other side requires — the emitted remoteEntry.js (or the plugin’s manifest) lists provided versions, and a small script comparing them against the host’s requiredVersion catches a drift before it deploys. Second, add a smoke test that loads the assembled host-plus-remotes in a headless browser and fails if the console emits Unsatisfied version or Invalid hook call, or if the share scope holds more than one version of any singleton.
// ci-check.js — fail the build on a duplicate singleton. Node 20, Playwright 1.4x
import { chromium } from 'playwright';
const SINGLETONS = ['react', 'react-dom'];
const browser = await chromium.launch();
const page = await browser.newPage();
const warnings = [];
page.on('console', (m) => { if (/Unsatisfied version|Invalid hook call/.test(m.text())) warnings.push(m.text()); });
await page.goto(process.env.PREVIEW_URL, { waitUntil: 'networkidle' });
const scope = await page.evaluate(() =>
Object.fromEntries(Object.entries(
window.__federation_shared__ ?? __webpack_share_scopes__?.default ?? {}
).map(([k, v]) => [k, Object.keys(v).length])));
const dupes = SINGLETONS.filter((p) => (scope[p] ?? 0) > 1);
await browser.close();
if (dupes.length || warnings.length) {
console.error('Federation version check failed:', { dupes, warnings });
process.exit(1);
}
Wire that into the pipeline that deploys the host, gated on the same preview URL the smoke test hits. It turns a class of silent runtime failure into a red build, which is the only place a version-negotiation bug is cheap to fix.
When not to share a dependency
Sharing is not free, and the reflex to add every common library to shared produces its own problems. A shared dependency couples the release cadence of every container that declares it: the moment a library is a singleton, no remote can adopt a breaking major of it until the host and every other remote are ready, because they must all negotiate one instance. For a foundational library like React that coupling is worth it — you genuinely cannot run two Reacts. For a fast-moving utility that each team upgrades independently, forcing a shared singleton converts an independent-deploy architecture back into a coordinated-release monolith, which is the exact thing federation was adopted to avoid.
The rule of thumb: share a dependency only when duplication is a correctness problem (shared state, a shared dispatcher, a shared context) or when the library is large enough that duplication is a meaningful size problem and its version is stable across teams. A small, stateless, frequently-updated utility fails both tests — leave it bundled per-remote, accept the few extra kilobytes, and keep the teams decoupled. Sharing a dependency you did not need to share does not just waste config; it manufactures a coordination requirement that will surface later as a remote that cannot ship because it is waiting on the host to bump a version.
Performance considerations
Shared dependencies change the shape of the loading waterfall, not just the byte count. A lazily-shared library adds a negotiation step the first time it is imported: the runtime must have the providing container’s metadata in hand before it can pick a version, so a remote that provides the winning copy has to be fetched before the shared module resolves. In practice this is invisible for host-provided singletons (the host is already loaded) but can add a round trip when the highest compatible version lives in a remote that has not loaded yet. Keep the canonical version of each heavy singleton on the host so the negotiation resolves against an already-present container.
The counter-lever is eager, and it trades latency for bundle weight in the other direction: an eager shared module is pulled into the host’s initial chunk, so it is available synchronously but inflates first paint. Use it only for dependencies the host evaluates before any remote loads. For everything else, lazy sharing keeps the initial bundle lean and pays the small negotiation cost only when a remote is actually navigated to — which, for a route-split micro-frontend, is exactly the trade you want.
Related
- Module federation and micro-frontend architectures — the parent cluster on federation.
- Sharing a singleton React instance across remotes — the React-specific singleton case in depth.
- Finding duplicate dependencies with npm overrides — the build-time analogue of runtime duplication.