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.

Duplicated vendor chunks versus deduplicated shared scope Left: three remotes each load their own React copy. Right: three remotes borrow one React instance from a shared scope. Without sharing With shared scope Remote A Remote B Remote C React copy 1 React copy 2 React copy 3 Remote A Remote B Remote C shared scope React ×1
Left: each remote ships its own React, tripling payload and breaking hooks. Right: one shared scope serves a single React instance to every remote.

What actually breaks #

Duplication is not a single bug — it shows up as three distinct failure modes, and each one needs a different fix.

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 #

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:

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 #

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.

// 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.

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.