Module Federation and Cross-Application Chunk Sharing

Everything else in this section assumes one build produces one application’s chunks. Module Federation breaks that assumption: it lets a build consume chunks produced by a different build, deployed on a different schedule, by a different team — resolving shared dependencies between them at runtime rather than at build time.

That capability solves a real organisational problem. Independently deployable frontends historically meant either an iframe, with its isolation costs, or a monorepo build, with its coordination costs. Federation offers a third option: separate builds, separate deploys, one runtime, and one copy of React. The price is that a whole class of resolution decisions — which normally happen deterministically at build time, as described in the build pipeline and module resolution fundamentals — now happen in the browser, where they can fail.

Understanding federation is therefore mostly about understanding what moved. Chunk generation still works the way the Webpack chunk generation lifecycle describes. What changes is that the manifest a host consults at runtime may describe files it did not build, hosted on an origin it does not control.

The architecture: hosts, remotes, and the shared scope

Three concepts carry the entire model.

A remote is a build that exposes named modules through a small entry file — the remote entry — that acts as a container. It declares what it publishes and what it needs.

A host is a build that consumes one or more remotes. It fetches the remote entry at runtime, asks it for a module, and receives a promise that resolves to that module’s exports.

The shared scope is a runtime registry both sides write into. Every package declared as shared is registered with its version, and when either side needs that package, the runtime resolves it from the scope instead of loading its own copy. This is where the byte saving comes from, and where nearly every federation bug originates.

Host, Remote, and the Shared Scope A host application fetches a remote entry file from another deployment; both builds register their shared dependencies in a common runtime scope, which resolves a single instance of each shared package before the exposed module is handed to the host. Host build shell, routing, layout declares: remotes + shared deployed independently Remote build checkout, search, reports declares: exposes + shared deployed independently remoteEntry.js container interface fetched at runtime Shared scope (runtime registry) react 18.3.1 · react-dom 18.3.1 · design-system 4.2.0 one instance each — highest satisfying version wins Resolution that normally happens at build time now happens in the browser

Configuration: Webpack 5 and Vite 5+

Webpack 5 ships federation in core. The remote declares what it exposes; the host declares where to find it; both declare what they are willing to share.

Three Declarations, Two Sides A remote declares its name and what it exposes; a host declares which remotes it consumes; both declare a shared set that must agree. What each side declares host remotes: name plus entry URL remote name plus the modules it exposes matched by name shared: declared identically on BOTH sides a package shared by only one side is bundled, not negotiated
// webpack.config.js — Webpack 5, the REMOTE build
const { ModuleFederationPlugin } = require('webpack').container;

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'reports',
      filename: 'remoteEntry.js',          // the container the host fetches
      exposes: {
        './ReportPanel': './src/ReportPanel',   // the public contract
      },
      shared: {
        // singleton: a second React instance breaks hooks and context.
        react: { singleton: true, requiredVersion: '^18.3.0' },
        'react-dom': { singleton: true, requiredVersion: '^18.3.0' },
        // Not a singleton: two design-system versions coexist safely.
        '@acme/design-system': { requiredVersion: '^4.0.0' },
      },
    }),
  ],
};
// webpack.config.js — Webpack 5, the HOST build
const { ModuleFederationPlugin } = require('webpack').container;

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'shell',
      remotes: {
        // Resolved at runtime, not baked in: the remote's URL must be
        // changeable per environment without rebuilding the host.
        reports: `reports@${process.env.REPORTS_URL}/remoteEntry.js`,
      },
      shared: {
        react: { singleton: true, requiredVersion: '^18.3.0' },
        'react-dom': { singleton: true, requiredVersion: '^18.3.0' },
        '@acme/design-system': { requiredVersion: '^4.0.0' },
      },
    }),
  ],
};

Vite reaches the same protocol through a plugin, with one structural difference worth knowing: Vite’s dev server serves unbundled ES modules, so federation in development behaves differently from federation in a production build.

// vite.config.js — Vite 5+, host consuming the same remote
import { defineConfig } from 'vite';
import federation from '@originjs/vite-plugin-federation';

export default defineConfig({
  plugins: [
    federation({
      name: 'shell',
      remotes: {
        reports: `${process.env.REPORTS_URL}/assets/remoteEntry.js`,
      },
      shared: ['react', 'react-dom', '@acme/design-system'],
    }),
  ],
  build: {
    // Federation requires an ES-module target with top-level await support.
    target: 'esnext',
    minify: false,   // keep the container interface legible while integrating
  },
});

Note what is not in either config: a version of the remote. Federation deliberately has no build-time coupling between host and remote — which is the point, and also the source of the operational risk. The host consumes whatever the remote’s URL is serving at the moment the user loads the page.

Loading a remote safely

A federated import is a network request to another origin, so it deserves the same treatment as any other lazy chunk — retry policy, error boundary, degraded state — as covered in handling lazy chunk load failures and fallbacks. The difference is that the failure modes include “another team deployed something broken twenty minutes ago.”

// ReportsRegion.jsx — React 18+: a federated remote behind a boundary
import { lazy, Suspense } from 'react';

// The federated specifier is resolved by the runtime, not by the bundler.
const ReportPanel = lazy(() => import('reports/ReportPanel'));

export function ReportsRegion({ fallback }) {
  return (
    <RemoteBoundary fallback={fallback}>
      {/* Reserve the box so a slow remote does not shift the page. */}
      <Suspense fallback={<div style={{ minHeight: 420 }} aria-busy="true" />}>
        <ReportPanel />
      </Suspense>
    </RemoteBoundary>
  );
}

Treat every remote as optional. A host whose shell fails to render because one remote is down has converted an independent-deployment architecture into a distributed monolith with extra network hops.

When federation is the wrong tool

Federation is an organisational solution wearing a technical costume, and it is worth being honest about when the trade is bad. The runtime complexity it introduces — cross-origin availability, version negotiation, a resolution step that can fail in the browser — only pays for itself when teams genuinely need to deploy on independent schedules and cannot coordinate a shared build.

If every participating application is built and released by the same pipeline, a shared library package in a monorepo achieves the same deduplication with none of the runtime risk: resolution happens at build time, where it is deterministic and testable, and the dependency graph stays legible to every analysis tool. The overhead of a monorepo is coordination at merge time, which is a cost paid by engineers during working hours rather than by users during incidents.

Federation earns its keep in a specific shape: several teams, separate release cadences, a shared design system and framework, and a shell that composes their work into one page. Outside that shape, the usual outcome is a distributed monolith — all the coupling of a single build, plus network calls between the pieces. A useful test before adopting it: if one team’s deploy would still require another team to redeploy, federation is not solving the problem you have.

The same caution applies to how much you federate. Exposing a handful of coarse, stable surfaces — a whole panel, a whole route — keeps the contract small and the negotiation simple. Exposing dozens of fine-grained components turns every shared utility into a runtime-resolved dependency and multiplies the number of ways a deploy can break someone else’s page.

Quantified impact

  • Shared framework bytes: downloaded once, not per application. In a shell plus three remotes, React and the design system account for roughly 130 KB gzipped; sharing them saves about 390 KB across a multi-application session.
  • Federation runtime overhead: 4–8 KB gzipped per participating build. Fixed cost, paid whether or not any remote is loaded.
  • Deploy independence: hours to minutes. Remotes ship without a host rebuild, which is the organisational benefit that justifies the runtime complexity.
  • Cold-load cost of a remote: one extra round-trip before its chunks are discoverable. The remote entry must be fetched and evaluated before the exposed module’s own chunks can even be requested — the same serialization problem described in preventing waterfall requests with dynamic import maps.
  • Failure surface: one additional origin per remote. Availability of the host page now depends on the availability of every origin it federates from, unless every region degrades independently.

Common pitfalls

Two React instances. The defining federation bug. A shared package declared without singleton: true, or with incompatible requiredVersion ranges, loads twice; hooks throw, context reads return defaults, and the errors point everywhere except the cause. Any package with module-level state — the framework, the router, the state library, styling runtimes — must be a singleton.

Baking the remote URL into the build. Hard-coding a remote’s origin means promoting a build between environments changes nothing about where it looks for remotes. Resolve remote URLs at runtime from configuration served with the page.

Version drift with no contract. Because there is no build-time coupling, nothing stops a remote from upgrading its framework major version on a Tuesday. Shared version ranges are the only enforcement mechanism, and they only produce a runtime warning. Teams need an agreed upgrade protocol on top of the tooling.

Eager shared modules. Marking a shared dependency as eager forces it into the initial chunk of every participating build, which defeats the negotiation entirely and reintroduces the duplication federation exists to prevent.

Ignoring the extra round-trip. A remote loaded on first paint costs a serialized request chain before anything renders. Remotes belong behind interaction or below the fold, or their entry needs to be preloaded alongside the host’s own critical chunks.

No integration test that loads the remote. Unit tests mock the federated import, so nothing exercises the actual container protocol. Every version mismatch, CORS misconfiguration, and protocol incompatibility surfaces first in production unless a test loads a real remote entry.

Singleton Negotiation Versus a Duplicated Instance On the left, host and remote both request React within a compatible range and the scope resolves one shared instance. On the right, incompatible ranges without a singleton flag produce two instances and broken hooks. Negotiated singleton — correct host wants react ^18.3.0 remote wants react ^18.2.0 one instance: react 18.3.1 hooks, context, and state all work No singleton — duplicated host wants react ^18.3.0 remote wants react ^17.0.0 react 18.3.1 host tree react 17.0.2 remote tree Invalid hook call — two copies of module-level state

Verification workflow

  1. Confirm the remote entry is reachable. Request the remote entry URL directly from the host’s origin and confirm a 200 with correct CORS headers. Most first-integration failures are CORS, not federation.

  2. Confirm exactly one instance of each singleton. With the application loaded, count the distinct instances of each shared package in the runtime’s share scope. Two entries for the same package name is the duplication bug, whether or not anything has visibly broken yet.

  3. Confirm the shared package is not duplicated on disk either. Check the built chunks: a shared package that also appears inside the remote’s own chunks means the sharing declaration is not taking effect for that build.

  4. Load the host with the remote blocked. Block the remote’s origin in DevTools and confirm the host still renders, the region degrades, and an error is reported — not a blank page.

  5. Deploy the remote independently and reload the host. The change must appear without rebuilding or redeploying the host. If it does not, something is baking remote content into the host build.

  6. Measure the request chain. Record the interval from navigation to the federated region’s first paint, and confirm the remote entry and its chunks are not serialized behind unrelated work. Preload the remote entry if the region is above the fold.

FAQ

Does Module Federation make bundles smaller?

It makes the total bytes across a set of applications smaller, and it makes any individual application’s bundle slightly larger. Each participating build carries the federation runtime and the negotiation logic, which is a fixed overhead of a few kilobytes. The saving comes from shared dependencies being downloaded once rather than once per application, which only pays off when users actually traverse multiple applications in a session. For a single application it is pure overhead.

What happens when the host and a remote need different versions of React?

That depends entirely on how the package is declared. Marked as a singleton with a required version, the runtime picks one instance and logs a warning if the other side’s requirement is unsatisfied — which keeps hooks working but risks subtle incompatibility. Marked as shared without singleton, both copies load, and any library relying on module-level state, React included, breaks in confusing ways. The correct answer is to treat the framework version as a contract negotiated between teams, not something the bundler can resolve at runtime.

Can Vite consume a Webpack-built remote?

Yes, through a federation plugin that implements the same runtime protocol, and with the caveat that the two toolchains produce different module formats internally. The interoperability point is the remote entry file and its container interface, not the bundler. In practice, mixed-toolchain federation works but needs an integration test that actually loads the remote in the host, because a protocol version mismatch surfaces only at runtime.

How should a host behave when a remote is unavailable?

It should degrade the region that consumed the remote and continue serving everything else. A remote is a network dependency loaded at runtime, so its failure is a normal operating condition, not an exception — a CDN incident, a bad deploy on another team’s schedule, or a slow edge is enough. Wrapping each federated region in an error boundary with a meaningful fallback is not optional hardening; it is the minimum viable integration.