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:

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 #

Runtime share scope negotiation Host and two remotes each register a React version into a shared scope; the loader picks one compatible singleton and a non-matching remote falls back to its own copy. Host react ^18.2.0 Remote A react ^18.0.0 Remote B react ^17.0.0 Share scope "default" react 18.2.1 (winner) highest compatible version wins Host + Remote A reuse winner Remote B loads own copy
Each container registers a version; the loader picks the highest compatible singleton, and a participant outside the range falls back to its own copy.

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.

  1. Registration. At init, each container calls __webpack_init_sharing__('default') (or the MF 2.0 runtime equivalent), writing its own React into the default scope under scope.react['18.3.1'], ['18.2.0'], ['17.0.2']. Each entry is a lazy get() factory, not a loaded module — registering is cheap and synchronous; nothing is fetched yet.
  2. First request. The host renders the lazy remote. Remote A’s rewritten import 'react' asks the scope to resolve react satisfying ^18.0.0.
  3. Candidate filtering. The loader collects every registered version that satisfies the requesting container’s range. For Remote A that’s 18.3.1 and 18.2.0; 17.0.2 is excluded.
  4. 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 it loaded, and hands the same module object to Remote A. The host now shares its React with Remote A; one instance, one hook dispatcher.
  5. The singleton mismatch path. When Remote B requests react for ^17.0.0, no candidate satisfies ^17.0.018.3.1 is too high. Because react is a singleton, the loader does not spin up a second instance; it reuses the already-chosen 18.3.1 and emits Unsatisfied 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.
  6. The non-singleton fallback path. Flip singleton to false for the same Remote B and the loader instead instantiates Remote B’s own 17.0.2 from 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:

Four more fields round out the contract when defaults aren’t enough:

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:

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:

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.