Configuring Webpack Module Federation #

Most teams reach for Module Federation expecting it to “just work” once the plugin is installed, then hit a wall on first integration: the host loads, the remote 404s its own chunks, React throws an “Invalid Hook Call,” and the dev server proxy serves a stale remoteEntry.js. None of these are bugs in Webpack. They are the predictable result of a configuration whose three moving parts — output paths, the exposed manifest, and the shared dependency scope — were set independently instead of as one contract.

This guide is the practical reference for getting that contract right. It sits under Webpack & Vite Module Federation Implementation, and it focuses narrowly on Webpack 5: how ModuleFederationPlugin turns a build into a federated container, how a host negotiates shared singletons with a remote at runtime, and how to ship the result without cache-poisoning your users.

For deeper dives, four companion guides go further than we can here: the step-by-step Webpack 5 container configuration walks a host and remote from empty folder to working integration; dynamically loading remote modules at runtime covers loading remotes whose URLs are unknown at build time; automatic publicPath configuration for remotes solves the cross-origin chunk problem properly; and sharing TypeScript types across federated remotes keeps interfaces honest between independently deployed builds.

What actually breaks without it #

Module Federation is a runtime composition mechanism. A remote build emits a small remoteEntry.js manifest plus content-hashed chunks; a host build emits the same, plus a list of remotes it intends to consume. When the host renders a federated component, the browser fetches the remote’s manifest, the two builds negotiate which copy of each shared library to use, and the exposed module is instantiated. Nothing about this is resolved at build time except the shape of the negotiation.

That late binding is the entire value proposition — and the source of every failure mode:

Get the config right and these become non-events. The rest of this guide is how.

Host and remote container wiring via remoteEntry The host loads the remote's remoteEntry.js, both register into a shared scope, version negotiation selects one copy of React, then the exposed module is instantiated. Host container name: hostApp remotes: { remoteApp } shared: react (singleton) Remote container name: remoteApp exposes: ./Dashboard shared: react (singleton) remoteEntry.js manifest Shared scope ("default") negotiate → one [email protected] Instantiate ./Dashboard 1 fetch 2 register shared deps 3 init
The host fetches the remote's manifest, both register into a shared scope, version negotiation picks one React, then the exposed module is instantiated.

Objectives #

This guide is done when you can:

Setup and config #

A federated build is just a normal Webpack build with one extra plugin. The plugin does three jobs: it names the container, it declares what the container exposes and consumes, and it declares the shared dependency contract. Below is an annotated host config; a remote is the mirror image (it exposes instead of declaring remotes).

// webpack.config.js (host)
const { ModuleFederationPlugin } = require('webpack').container;
const deps = require('./package.json').dependencies;

module.exports = {
  output: {
    // 'auto' makes Webpack infer publicPath from the document at runtime,
    // so a remote's chunks load from the remote's origin, not the host's.
    publicPath: 'auto',
    // uniqueName prevents chunk-loading collisions when multiple containers
    // share one global runtime. Default to the package name; make it unique.
    uniqueName: 'hostApp',
  },
  plugins: [
    new ModuleFederationPlugin({
      // The global variable the container registers under. Must match the
      // key used in any consumer's `remotes` map.
      name: 'hostApp',
      // The manifest filename. Keep it stable across deploys so consumers'
      // remote URLs never change.
      filename: 'remoteEntry.js',
      // What this build consumes. The right-hand side is `name@url`.
      remotes: {
        remoteApp: 'remoteApp@https://remotes.example.com/dashboard/remoteEntry.js',
      },
      // What this build hands to consumers. Hosts often expose shared shell
      // pieces (auth, layout) even while consuming feature remotes.
      exposes: {
        './AuthProvider': './src/providers/AuthProvider.tsx',
      },
      // The negotiated dependency contract. See the next section.
      shared: {
        react: { singleton: true, requiredVersion: deps.react },
        'react-dom': { singleton: true, requiredVersion: deps['react-dom'] },
      },
    }),
  ],
};

A few things are easy to get wrong here. name is a global identifier, not a label — it has to match what consumers put in their remotes map exactly. filename becomes part of the public contract the moment another team points at it, so treat a rename as a breaking change. And reading requiredVersion from package.json rather than hardcoding '^18.2.0' means your version range can’t silently drift away from what you actually installed.

The shared contract is the hard part #

shared is where federation earns its reputation. Each entry tells the runtime how to negotiate one library across containers. The three knobs that matter:

shared: {
  react: {
    singleton: true,        // exactly one copy in the whole composition
    requiredVersion: deps.react,
    eager: false,           // load lazily, on first use, not in the initial chunk
  },
  '@mui/material': {
    singleton: true,
    requiredVersion: '^5.14.0',
    strictVersion: true,    // hard-fail on an incompatible version instead of warning
  },
  lodash: {
    singleton: false,       // fine to have multiple copies; no shared state
    requiredVersion: '^4.17.21',
  },
}

singleton: true is mandatory for anything with module-level state that the framework assumes is unique — React, react-dom, your router, your store. Without it the runtime is allowed to load a second copy, and that second copy is exactly the “Invalid Hook Call” bug. Libraries with no shared state (most utility libraries) can stay singleton: false, which is more forgiving across version drift.

eager: true forces a dependency into the initial chunk instead of resolving it lazily. The host’s framework deps usually want eager: true so they’re available before any remote loads; remotes almost always want eager: false so they don’t ship a redundant copy. Marking everything eager is a common cause of bloated initial bundles. How far to push this — and what requiredVersion ranges survive real upgrades — is the subject of managing shared dependencies at runtime.

strictVersion: true turns a version mismatch from a console warning into a thrown error. Use it on libraries where running the wrong version is worse than a hard failure (UI kits with breaking style changes, anything with peer-dependency assumptions). Leave it off where graceful degradation is acceptable.

Integration: host plus remote wiring #

The plugin config sets up the negotiation; the host has to actually trigger it. With remotes declared statically (as above), you can import a remote module with a normal dynamic import and Webpack rewrites it into the federation handshake for you:

// host: consuming a statically-declared remote
import React, { Suspense } from 'react';

const RemoteDashboard = React.lazy(() => import('remoteApp/Dashboard'));

export function DashboardPage() {
  return (
    <Suspense fallback={<DashboardSkeleton />}>
      <RemoteDashboard />
    </Suspense>
  );
}

That import('remoteApp/Dashboard') looks like a normal module specifier but resolves through the remotes map at runtime: fetch remoteEntry.js, init the shared scope, get the ./Dashboard factory, instantiate it. The Suspense boundary handles the inherent latency of fetching a remote over the network.

When the remote’s URL isn’t known at build time — for example when it’s environment-specific or chosen per tenant — you drop the static declaration and drive the low-level runtime API yourself:

// host: loading a remote whose URL is resolved at runtime
type Factory<T> = () => T;

async function loadRemote<T>(
  remoteUrl: string,
  scope: string,
  module: string,
): Promise<T> {
  // 1. Inject the remote's manifest. It registers window[scope] as a side effect.
  await import(/* webpackIgnore: true */ remoteUrl);

  // 2. Initialise the host's shared scope so version negotiation can run.
  await __webpack_init_sharing__('default');

  // 3. Hand the shared scope to the remote container so it reuses host singletons.
  const container = (window as any)[scope];
  if (!container) throw new Error(`Remote '${scope}' did not register`);
  await container.init(__webpack_share_scopes__.default);

  // 4. Pull the exposed factory and instantiate it.
  const factory: Factory<T> = await container.get(module);
  return factory();
}

Order matters in that sequence. __webpack_init_sharing__ must run before container.init, or the remote initialises with an empty scope and loads its own React. The static-import path does all of this for you; the manual path is only worth the boilerplate when build-time URLs genuinely won’t work. The full pattern, including registry-driven remote discovery, is covered in dynamically loading remote modules at runtime.

On the remote side, there’s nothing to “wire” beyond exposing the module and matching the shared contract:

// webpack.config.js (remote)
new ModuleFederationPlugin({
  name: 'remoteApp',
  filename: 'remoteEntry.js',
  exposes: {
    './Dashboard': './src/Dashboard.tsx',
  },
  shared: {
    react: { singleton: true, requiredVersion: deps.react, eager: false },
    'react-dom': { singleton: true, requiredVersion: deps['react-dom'], eager: false },
  },
});

The one rule that connects the two configs: the remote’s name must equal the key the host used in remotes, and the shared entries should agree on what’s a singleton. Mismatch either and the handshake degrades to two isolated containers, which loads but reintroduces every duplication bug.

Edge cases #

publicPath and cross-origin chunks. remoteEntry.js loads fine, then the remote’s lazy chunks 404. Almost always this is a publicPath that resolved to the host’s origin. publicPath: 'auto' fixes most cases by inferring the path from the running script; serving a remote from a CDN sub-path or behind a rewrite needs the explicit runtime approach in automatic publicPath configuration for remotes.

Version negotiation losing. When host and remote declare overlapping but non-identical ranges, the runtime picks the highest version that satisfies everyone — but if a remote genuinely needs an API the chosen version lacks, it fails at the call site. strictVersion surfaces this as a load-time error rather than a mysterious runtime crash. Plan version ranges as a contract, not an afterthought.

Eager consumption and the bootstrap split. If a host eagerly consumes a shared dependency, that dependency must be available synchronously at startup — which conflicts with federation’s lazy model. The standard fix is the bootstrap.js indirection: your entry file is a single import('./bootstrap'), deferring all real code past the point where the shared scope is ready. Skip this and eager singletons throw “Shared module is not available for eager consumption.”

Partial loads and stale manifests. A remote that deploys mid-session can leave a host holding a manifest that references chunks that no longer exist. Content hashing plus a short-TTL manifest (below) bounds the window, but the host still needs an error boundary so a failed remote degrades to a fallback instead of taking down the page.

Testing and validation #

Federation depends on globals (__webpack_init_sharing__, __webpack_share_scopes__, window[scope]) that don’t exist in a test runner, so unit tests must stub them. The higher-value tests are integration tests that exercise the real failure modes.

Deployment #

The reason to adopt federation is independent deployment, and the configuration that enables it is almost entirely about caching. The split is simple: the manifest must be fresh, everything else must be immutable.

output: {
  publicPath: 'auto',
  // Every chunk except the manifest gets a content hash for immutable caching.
  filename: '[name].[contenthash].js',
  chunkFilename: '[name].[contenthash].js',
}
# cache headers at the edge / origin
remoteEntry.js:
  Cache-Control: "no-cache, must-revalidate"   # always revalidate the manifest
"*.[contenthash].js":
  Cache-Control: "public, max-age=31536000, immutable"  # cache hashed chunks forever

With this in place a remote deploy is: upload the new hashed chunks, then overwrite remoteEntry.js. Because the manifest is no-cache, the next host request picks up the new chunk names; because the old chunks are still present, any session mid-load doesn’t break. The host never rebuilds.

A safe rollout sequence for a remote:

  1. Build with content hashing so old and new chunks coexist at the origin.
  2. Publish the new chunks first, then the manifest — never the reverse, or the manifest will point at chunks that aren’t uploaded yet.
  3. Don’t purge hashed chunks on a CDN; only ever invalidate remoteEntry.js. Hashed assets are immutable by construction, so purging them only costs cache hits.
  4. Gate behind a flag or canary remote URL for risky changes — point a fraction of hosts at the new remoteEntry.js and watch error rates before flipping the rest.
  5. Keep an error boundary around every remote so a bad deploy degrades to a fallback rather than a blank page, and wire its telemetry so you find out before users do.

For the surrounding bundler decision — when Vite’s faster dev loop is worth its thinner federation tooling — see setting up Vite with federation plugins.

Common pitfalls #

Issue Root cause & resolution
remoteEntry.js loads but the remote’s chunks 404 publicPath resolved to the host’s origin. Use publicPath: 'auto', or set it at runtime when the remote lives behind a CDN sub-path or rewrite.
“Invalid Hook Call” / hooks misbehave after loading a remote Two React instances in one composition. Mark react and react-dom singleton: true in both configs and align their requiredVersion ranges.
“Shared module is not available for eager consumption” A shared dep is consumed eagerly before the shared scope is ready. Move the entry behind a bootstrap.js dynamic import, or set the dependency eager: false.
Remote works in dev, fails in prod with a CORS error The remote origin lacks Access-Control-Allow-Origin for the host. Add CORS headers on the remote (and on the CDN), or co-locate origins.
Host shows a stale remote after the remote deployed remoteEntry.js was cached. Serve the manifest no-cache/must-revalidate while content-hashing every other chunk.
Initial bundle is far larger than expected Too many eager: true shared deps, or remotes shipping their own copy of a library the host already shares. Default remotes to eager: false and audit the shared list.

FAQ #

Do the host and remote both need to declare a dependency as singleton?

Yes, for any stateful library. The singleton guarantee is only honored if every container that shares the dependency agrees it’s a singleton. If the host marks React singleton: true but a remote leaves it off, the remote is free to load its own copy, and you’re back to the dual-instance hook bug. Treat the shared block as a contract that both sides sign identically.

What’s the difference between requiredVersion and strictVersion?

requiredVersion is the semver range a container is willing to accept; the runtime negotiates the highest version that satisfies all participants. strictVersion controls what happens when no version satisfies a container: with it off, the runtime warns and proceeds with a best-effort copy; with it on, it throws. Use strictVersion: true where running the wrong version is more dangerous than failing loudly.

Can the host load a remote whose URL it doesn’t know at build time?

Yes. Drop the remote from the static remotes map and use the low-level runtime API — inject the manifest with a dynamic import, call __webpack_init_sharing__('default'), then container.init(...) and container.get(...). The order is non-negotiable: initialise sharing before initialising the container. The full pattern is in dynamically loading remote modules at runtime.

How do I deploy a remote without rebuilding the host?

Keep filename: 'remoteEntry.js' stable and content-hash every other chunk. Serve the manifest with no-cache and the hashed chunks as immutable. Then a deploy is just uploading new hashed chunks and overwriting the manifest — the host fetches the fresh manifest on its next load and never needs to know the chunk names changed.