Managing Shared Dependencies at Runtime #
A federated app is many independently built bundles meeting for the first time in the browser. Each one was compiled in isolation, against its own node_modules, with its own idea of which version of React, of a design system, of a date library it needs. Runtime dependency management is the negotiation that happens when those bundles finally share a page — and when it goes wrong, the failure is rarely a clean error. You get two React copies silently disagreeing about which one owns the hook dispatcher, a context provider whose value reads undefined in a consumer three remotes away, or a 400 KB framework downloaded twice because two semver ranges never overlapped.
This guide covers how Module Federation resolves shared modules at runtime, how to configure the shared contract so the negotiation lands where you want it, and how to deploy and observe it without surprises. It sits under Webpack & Vite Module Federation Implementation, and the deeper, framework-specific problems get their own treatment: the mechanics of resolving version conflicts in shared React libraries when ranges don’t line up, and the harder cross-toolchain case of sharing singletons across Webpack and Vite remotes where two build systems must agree on one global instance.
A note on tooling currency, since the runtime model changed under our feet. As of 2026 the canonical implementation is Module Federation 2.0, shipped as @module-federation/enhanced for Webpack and Rspack, and @module-federation/vite for Vite and Rolldown. The classic webpack.container.ModuleFederationPlugin still works and still exposes the __webpack_share_scopes__ runtime, so everything here applies to legacy setups too. But MF 2.0 adds a unified runtime (@module-federation/runtime) that all toolchains share, a shareStrategy knob that controls when the scope is loaded, runtime plugins for hooking the negotiation, and — critically for mixed estates — a single share-scope model that Webpack and Vite remotes can both register into. The conceptual contract below is identical across versions; the API surface is what modernized.
What actually breaks #
The whole problem is that “shared” is a runtime promise made at build time, and the two halves can drift apart.
When you mark react as shared, the build does not bundle React into every chunk that imports it. Instead each container registers what versions of React it can provide into a global share scope, and rewrites its imports to ask the scope for a copy rather than reaching into its own bundle. The first remote to need React triggers a negotiation: the loader looks at every registered version, applies the consumer’s requiredVersion range, and picks a winner. Everyone compatible reuses it. Anyone whose range excludes the winner falls back to loading their own copy.
That fallback is the failure mode. It is not an error — federation is doing exactly what you told it — but the symptoms are ugly:
- Duplicate frameworks. Two React instances on one page means hooks throw
Invalid hook call, or worse, render silently with the wrong dispatcher. Two copies of a state library means two stores that never sync. - Broken context. A provider rendered by the host and a consumer rendered by a remote only connect if they share one module instance. A second copy gives the consumer a fresh, empty context.
- Bundle bloat. The performance win of federation is shipping one copy of a heavy library. A range mismatch quietly undoes it, and nobody notices until the network tab shows
react-domtwice. This is the same waste explored from the build side in avoiding bundle duplication. - Eager-load deadlocks. Mixing
eager: trueand dynamic remotes can force a shared module into the initial chunk before sharing is initialized, producingShared module is not available for eager consumption.
The job is to make the runtime negotiation deterministic, so the same set of remotes always resolves to the same set of shared instances.
Key objectives #
- One instance where it matters. Stateful and context-bearing libraries (React, the router, your store, the design system) must resolve to a single shared instance across host and every remote.
- Predictable version resolution. Ranges across all participants must overlap so the negotiation has a legal winner — no accidental duplicates from over-strict
requiredVersion. - Lazy by default. Shared modules load on demand alongside the remotes that need them, never eagerly inflating the host’s first paint.
- Observable at runtime. You can inspect the live share scope and assert, in CI and in production, that the expected single instances actually won.
- Safe to deploy independently. A remote can ship a new version of itself without forcing a host redeploy or breaking the shared contract.
How the negotiation actually runs #
It helps to trace the algorithm step by step, because every config field below is just a lever on this one sequence. Take a host on [email protected] declaring requiredVersion: "^18.2.0", Remote A on [email protected] declaring ^18.0.0, and Remote B on [email protected] declaring ^17.0.0. All three mark react as a shared singleton.
- Registration. At init, each container calls
__webpack_init_sharing__('default')(or the MF 2.0 runtime equivalent), writing its own React into thedefaultscope underscope.react['18.3.1'],['18.2.0'],['17.0.2']. Each entry is a lazyget()factory, not a loaded module — registering is cheap and synchronous; nothing is fetched yet. - First request. The host renders the lazy remote. Remote A’s rewritten
import 'react'asks the scope to resolvereactsatisfying^18.0.0. - Candidate filtering. The loader collects every registered version that satisfies the requesting container’s range. For Remote A that’s
18.3.1and18.2.0;17.0.2is excluded. - Highest compatible wins. Among the candidates, the loader sorts by semver and takes the maximum —
18.3.1, the host’s copy. It loads that one factory, marks itloaded, and hands the same module object to Remote A. The host now shares its React with Remote A; one instance, one hook dispatcher. - The singleton mismatch path. When Remote B requests
reactfor^17.0.0, no candidate satisfies^17.0.0—18.3.1is too high. Becausereactis a singleton, the loader does not spin up a second instance; it reuses the already-chosen18.3.1and emitsUnsatisfied version 18.3.1 ... from 'react' (required ^17.0.0). Remote B runs against React 18 whether or not that is safe — the warning is your signal to fix the range or pin a compatible remote. - The non-singleton fallback path. Flip
singletontofalsefor the same Remote B and the loader instead instantiates Remote B’s own17.0.2from its bundle. No warning, no error — and now two Reacts on the page. This is precisely why stateful libraries must be singletons: the “safe” non-singleton behavior is what produces duplicates.
The takeaway: the winner is always the highest version that satisfies the requesting consumer’s range, evaluated per request against whatever is registered at that moment. Singletons collapse mismatches into a warning; non-singletons collapse them into a second copy. Load order matters only at the margins — a later-loading container can still reuse an instance an earlier one chose.
Setup / Config: the shared contract #
The shared block is where you encode all of this. It is the same idea in Webpack and Vite, with small dialect differences. Start from the canonical Webpack form covered in configuring Webpack Module Federation and add the runtime-specific fields.
// webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;
const deps = require('./package.json').dependencies;
module.exports = {
output: { publicPath: 'auto', uniqueName: 'host_app' },
plugins: [
new ModuleFederationPlugin({
name: 'host_app',
remotes: {
remote_app: 'remote_app@https://cdn.example.com/remote/remoteEntry.js',
},
shared: {
react: {
singleton: true, // exactly one instance across all containers
requiredVersion: deps.react, // read from package.json, not hardcoded
eager: false, // load with the remote, not in the host shell
},
'react-dom': {
singleton: true,
requiredVersion: deps['react-dom'],
eager: false,
},
'@company/design-system': {
singleton: true, // shared theme + context need one instance
requiredVersion: deps['@company/design-system'],
},
lodash: {
singleton: false, // stateless util: dedupe if possible, duplicate is harmless
requiredVersion: '^4.17.21',
},
},
}),
],
};
Three fields carry the weight:
singletondecides whether a version mismatch duplicates (false) or forces one instance and warns (true). Set ittruefor anything that holds state, registers globals, or backs a context: React, React-DOM, the router, your store, the design system. Leave itfalsefor pure functions likelodashwhere a second copy is wasteful but never wrong.requiredVersionis the compatibility range used during negotiation. Read it frompackage.json(deps.react) instead of hardcoding a string — that keeps the declared range and the actually-installed version from drifting apart on upgrade.eagercontrols when the module loads. Keep itfalseso shared code is fetched alongside the remote that needs it.eager: truepulls it into the host’s initial chunk, which only makes sense for a tiny set of dependencies the host itself renders before any remote mounts.
Four more fields round out the contract when defaults aren’t enough:
strictVersion: trueupgrades a singleton version mismatch from a warning to a thrown error. Pair it withsingleton: truewhen running an incompatible version would corrupt state rather than merely waste bytes — you want the page to fail loudly in CI, not limp along with React 18 driving a component written for React 17.requiredVersion: falsedisables range enforcement for that container: it accepts whatever the scope offers. This guarantees sharing for a remote you’re willing to run against the host’s version unconditionally, at the cost of the safety net.shareKeydecouples the import specifier from the scope identity. Two remotes importinglodashandlodash-escan be made to resolve to one entry by giving both the sameshareKey. Without it, federation treats different specifiers as unrelated modules.versionlets you override the declared provided version whenpackage.jsonlies — for instance, a vendored or patched build whose installed metadata no longer reflects what the code actually is.
In MF 2.0 you can also set a top-level shareStrategy on the plugin. version-first (the default) loads the share scope before resolving any remote, which is the deterministic behavior described above. loaded-first defers scope construction until a remote is actually requested, trimming startup work when many remotes are declared but few are used per session. Stick with version-first unless you’ve measured the init cost and confirmed loaded-first is safe for your singleton set.
The Vite federation plugin mirrors the contract; the differences are dialect, not concept.
// vite.config.ts
import { defineConfig } from 'vite';
import { federation } from '@module-federation/vite';
import pkg from './package.json';
export default defineConfig({
plugins: [
federation({
name: 'host_app',
remotes: {
remote_app: 'https://cdn.example.com/remote/remoteEntry.js',
},
shared: {
react: { singleton: true, requiredVersion: pkg.dependencies.react },
'react-dom': { singleton: true, requiredVersion: pkg.dependencies['react-dom'] },
},
}),
],
// Federation needs predictable module identity; let the plugin own these deps.
build: { target: 'esnext' },
});
Vite resolves shared modules through native ESM and defers them to dynamic-import time by default, so there is no eager knob to misconfigure — but that same ESM model is exactly what makes mixing Vite and Webpack remotes delicate, which is why it gets its own singletons-across-toolchains guide.
Webpack ↔ Vite interop in one estate #
A host and remotes built with different toolchains can share dependencies — MF 2.0’s common @module-federation/runtime is what makes it possible — but only when three things line up. First, the share scope name must match: both sides default to default, so don’t override shareScope on one and not the other. Second, the provided versions must overlap under the same package name; @module-federation/vite reads requiredVersion from package.json exactly as the Webpack plugin does, so keep the ranges loose and aligned. Third, module identity must agree — a Webpack remote that ships CommonJS React and a Vite remote that imports the ESM build can register two entries under react that the negotiation treats as one version but that are not Object.is-identical at runtime, reintroducing the duplicate you were avoiding. The practical rule is to make the host the single source of truth for stateful singletons (let it provide React, the router, the store) and have Vite remotes consume rather than provide them. The full treatment, including the format-mismatch traps, is in sharing singletons across Webpack and Vite remotes.
Integration: wiring host and remotes #
The shared contract only pays off if both sides defer to it. On the host, load remotes lazily so the runtime can initialize sharing before any shared module is requested.
// host/src/App.tsx
import { lazy, Suspense } from 'react';
// Dynamic import lets Module Federation negotiate the share scope
// before the remote's code (and its React import) executes.
const RemoteWidget = lazy(() => import('remote_app/Widget'));
export function App() {
return (
<Suspense fallback={<Spinner />}>
<RemoteWidget />
</Suspense>
);
}
Inside the remote, write ordinary imports. The build rewrites them to consult the share scope, so the remote does not need to know whether it is running standalone or federated.
// remote_app/src/Widget.tsx
import React, { useContext } from 'react';
import { ThemeContext } from '@company/design-system';
export default function Widget() {
// Resolves to the SAME ThemeContext instance the host provided,
// because @company/design-system is a shared singleton.
const theme = useContext(ThemeContext);
return <div style={{ color: theme.fg }}>Hello from the remote</div>;
}
If the remote can also run on its own, declare its singletons the same way in its config. A singleton is only honored when every container that touches the module agrees it is one — one side declaring singleton: true and the other omitting it produces exactly the duplicate you were trying to avoid.
Edge cases #
Version negotiation when ranges don’t overlap #
The negotiation only succeeds if there is a version that satisfies every consumer’s requiredVersion. Picture three remotes pinned to ^18.2.0, ^18.0.0, and ^17.0.0. The first two overlap on 18.x and share one instance; the third has no legal point of agreement and falls back to its own React 17 — and now you have two Reacts on the page.
You have a few levers:
- Widen the ranges. Caret ranges (
^18.2.0) overlap across minors; pinning exact versions (18.2.0) guarantees mismatches the moment anyone bumps. Prefer carets for shared deps. - Let the highest compatible win. Federation already picks the highest version satisfying all ranges. Keeping ranges loose lets that mechanism do its job.
- Drop the floor with
requiredVersion: false. This tells a container to accept whatever the scope offers without enforcing its own range — useful for a remote you are willing to run against the host’s version unconditionally. Use it sparingly; it trades safety for guaranteed sharing. - Force a strict failure.
strictVersion: trueturns an unsatisfiable range into a hard error instead of a silent duplicate, which is sometimes what you want in CI. The React-specific tactics live in resolving version conflicts in shared React libraries.
Singletons that quietly become plural #
A singleton: true declaration prevents the error, not necessarily the duplicate. If a remote’s range can’t be satisfied by the chosen instance, federation honors singleton by using the winner anyway and logging a warning — but only if the remote actually participates in the shared scope. The common ways a singleton silently splits in two:
- One container declares the dep shared, another bundles it normally (forgot the
sharedentry). - A Webpack remote and a Vite remote disagree on module identity because they resolve the package through different mechanisms.
- A dependency is imported under two different specifiers (
lodashvslodash-es) — to federation these are two unrelated modules.
The fix is always the same: make sure every container lists the same package, by the same name, as singleton: true. Verify it from the live scope rather than trusting the config (below).
Eager consumption errors #
Shared module is not available for eager consumption means a shared module was needed synchronously before sharing was initialized — almost always from mixing eager: true with a synchronous entry. The standard remedy is the bootstrap split: keep eager: false and move your app’s entry behind a dynamic import.
// src/index.js — thin entry, no top-level app imports
import('./bootstrap');
// src/bootstrap.js — real app entry; sharing is initialized by now
import { createRoot } from 'react-dom/client';
import { App } from './App';
createRoot(document.getElementById('root')).render(<App />);
Testing / Validation #
The share scope is a live object, so the most reliable validation reads it directly. After __webpack_init_sharing__('default') resolves, __webpack_share_scopes__.default has the shape { [pkgName]: { [version]: { get, from, eager, loaded? } } } — one entry per registered version, tagged with the container it came from.
// share-scope-inspector.js
// Run after __webpack_init_sharing__('default') completes.
function inspectShareScope() {
const scope = __webpack_share_scopes__.default;
const duplicates = [];
for (const [pkgName, versionMap] of Object.entries(scope)) {
const versions = Object.keys(versionMap);
if (versions.length > 1) {
duplicates.push({ pkgName, versions });
console.warn(`[Federation] '${pkgName}' has multiple versions:`, versions);
} else {
const [version] = versions;
const { from, loaded } = versionMap[version];
console.log(`[Federation] '${pkgName}@${version}' from '${from}' (loaded: ${!!loaded})`);
}
}
return duplicates;
}
export async function auditSharedDeps() {
await __webpack_init_sharing__('default');
return inspectShareScope();
}
Turn that into an assertion in an end-to-end test so a regression fails the build instead of reaching production:
// share-scope.e2e.ts (Playwright)
import { test, expect } from '@playwright/test';
test('no duplicate singletons in the share scope', async ({ page }) => {
await page.goto('/');
await page.getByTestId('remote-widget').waitFor(); // ensure remotes loaded
const dupes = await page.evaluate(async () => {
await (window as any).__webpack_init_sharing__('default');
const scope = (window as any).__webpack_share_scopes__.default;
const singletons = ['react', 'react-dom', '@company/design-system'];
return singletons.filter((p) => scope[p] && Object.keys(scope[p]).length > 1);
});
expect(dupes, `duplicated singletons: ${dupes.join(', ')}`).toEqual([]);
});
Complementary checks worth automating: a Jest test that imports react from two stubbed remotes and asserts Object.is identity of the module; a CI step that diffs declared requiredVersion ranges across every package.json to catch non-overlapping pins before runtime; and tracking installed-vs-shared version drift, which connects to measuring bundle size impact of shared dependencies.
Deployment #
Independent deployability is the point of federation, so the shared contract has to survive remotes shipping on their own schedule.
Cache versioned chunks hard, never the entry. Shared chunks carry content hashes (react.8f3b2c.js), so serve them with Cache-Control: public, max-age=31536000, immutable. The remoteEntry.js manifest, by contrast, must stay fresh — give it Cache-Control: no-cache (or a very short TTL) so a redeployed remote is picked up immediately.
Pin the manifest contract, not the chunk URLs. The host references remotes by their entry manifest. As long as a remote keeps exposing the same module names with compatible shared ranges, it can rebuild and redeploy without touching the host.
Allow cross-origin script execution. Remote entries fetched from a CDN need Access-Control-Allow-Origin set, or the browser refuses to run them. This is the single most common production-only failure.
Roll out behind a flag and watch the scope. Gate a new remote version behind a feature flag, and emit telemetry from the inspector above — count distinct versions per singleton as a metric. A spike from 1 to 2 on react is your duplicate-instance alarm. Pair that with RUM on shared-chunk load time and fallback frequency so you see drift before users do.
Have a rollback that flips the manifest. Because the host resolves remotes through the manifest at runtime, rollback is repointing the entry URL (or flag) at the previous remote build — no host redeploy required.
Common pitfalls #
| Issue | Root cause & resolution |
|---|---|
| Duplicate framework instances | A shared dep is missing singleton: true, or one container omits the shared entry entirely. Declare it singleton: true in every container, by the same package name. |
Invalid hook call / empty context |
Two React or two provider instances on the page. Confirm one entry in the live share scope; align ranges so the singleton’s version satisfies every consumer. |
| Same library counted twice | Imported under two specifiers (lodash vs lodash-es) or two non-overlapping pinned versions. Normalize the specifier; use caret ranges so versions overlap. |
Shared module is not available for eager consumption |
A shared module needed synchronously before sharing initialized. Keep eager: false and use the import('./bootstrap') entry split. |
| Bundle ships a dependency twice | Over-strict requiredVersion (exact pins) forces a fallback copy. Widen to caret ranges; verify overlap across all package.json files. |
| Remote entry blocked in production | Missing Access-Control-Allow-Origin on the CDN origin. Configure CORS to allow cross-origin script execution for remoteEntry.js and chunks. |
| Redeployed remote not picked up | remoteEntry.js was cached. Serve the manifest no-cache; only content-hashed chunks get the immutable, year-long TTL. |
| Singleton silently runs the wrong major | A version-mismatch warning was ignored, so a remote built for React 17 runs on 18. Set strictVersion: true on the singleton to convert the warning into a build-failing error, then fix the range. |
| Webpack and Vite remotes still duplicate React | Different module formats (CJS vs ESM) register non-identical instances under one version key. Make the host the sole provider of stateful singletons; have the other toolchain’s remotes consume only. |
| Slow host startup with many remotes | shareStrategy: version-first loads the whole scope up front. If only a few remotes load per session, measure and consider loaded-first to defer scope construction. |
shareKey not honored / two entries persist |
A shareKey was set on one container but not its peers. Apply the same shareKey (and package name) in every container, or normalize the import specifier instead. |
FAQ #
What happens when a remote requires a newer shared version than the host can provide?
Module Federation tries to satisfy the remote’s requiredVersion from the share scope. If no registered version qualifies, the remote loads its own copy — a duplicate, not an error. With singleton: true it instead reuses the chosen instance and logs a version-mismatch warning; with strictVersion: true it throws. Widening ranges so they overlap is the durable fix.
Can I update a shared dependency without redeploying the host?
Yes. The host resolves remotes through their remoteEntry.js manifest at runtime, so a remote can rebuild with a new shared version and redeploy on its own — provided its range still overlaps the host’s, the manifest is served no-cache, chunks are content-hashed and immutable, and CORS allows cross-origin execution.
How do I tell whether a module is actually loading twice?
Read the live scope: await __webpack_init_sharing__('default') then inspect __webpack_share_scopes__.default. More than one version key under a package means a duplicate. Cross-check the network waterfall for the same library fetched twice, and confirm singleton: true is set in every container. The inspector and Playwright assertion above turn this into a standing check.
Should everything be a singleton?
No. Reserve singleton: true for libraries that hold state, register globals, or back a context — React, the router, the store, the design system. Stateless utilities like lodash should be shared with singleton: false: federation deduplicates them when ranges allow, and a second copy is merely wasteful, never incorrect. Marking everything a singleton just multiplies the chances of a version-mismatch warning.