Module Federation and Micro-Frontend Architectures

Module federation moves dependency resolution from compile time to runtime, so a host application can fetch and execute a remote’s chunks over HTTP without rebuilding either side. That single shift — a host loading remoteEntry.js at runtime and negotiating a shared scope — is what lets independently deployed micro-frontends share one React instance, one router, and one design system while keeping separate CI pipelines. For the underlying graph, chunk, and manifest model this builds on, read Core Concepts of Modern Bundling before wiring host/remote boundaries. This guide covers the runtime sharing mechanics, complete @originjs/vite-plugin-federation and Webpack 5 ModuleFederationPlugin setups, a numbered bring-up workflow, the failure modes you will actually hit, and a version compatibility matrix.

The problem federation solves is organizational before it is technical. A single-bundle application forces every team that contributes UI into the same build, the same dependency lockfile, and the same release train: one team’s flaky test blocks everyone’s deploy, and a shared dependency bump has to be negotiated across every feature area at once. The naive escape hatch — give each team its own independently built bundle and stitch them together with iframes or a runtime script loader — works, but it reships the framework in every fragment. Load four such fragments and the browser parses four copies of React, runs four reconcilers, and holds four separate context trees that cannot see each other. Federation is the middle path: separate builds and separate deploys, but a shared runtime that hands every fragment the same framework instance.

Concretely, the mechanism lives at the seam between the module graph and the network. During its build each application still produces a normal chunk graph, but the federation plugin carves out the modules named in exposes and shared and wraps them in an async factory registered on a container object. The host never statically imports the remote; instead it emits a call that, at runtime, fetches remoteEntry.js, reads the container’s manifest of exposed modules, and resolves the requested one — only after both sides have contributed their shared dependencies to a common registry. Everything downstream (which React copy wins, whether a chunk 404s, whether hooks throw) is a consequence of that negotiation, so the rest of this page is organized around it: first the registry and resolution algorithm, then the configuration that feeds it, then the failure modes that appear when the negotiation resolves to something you did not intend.

Host and remote runtime module sharing via a shared scope A host loads remoteEntry.js over HTTP, both sides register react into a shared scope, and the scope resolves a single negotiated version at runtime. Host app (shell) remotes: { profile } import('profile/Card') provides react@18.3.1 Remote app (profile) exposes: './Card' emits remoteEntry.js requires react@^18 HTTP GET remoteEntry.js Shared scope singleton: true, requiredVersion: ^18 resolves -> react@18.3.1 (one copy) Both sides register; the scope hands back one negotiated instance
Figure: a host fetches a remote's entry over HTTP, then both register their copies of React into a shared scope that resolves a single instance.

Prerequisites

Pin the tool versions before touching federation config; resolution behavior changed materially across major versions. Federation is one of the few build features where the exact plugin and bundler version is load-bearing rather than incidental — the share-scope registry format, the semver matching rules, and the default eager behavior all shifted between releases, and a config copied from a blog post written against an older version will fail in ways whose error messages point at the wrong cause. Treat the version list below as part of the contract, not as a suggestion.

  • Webpack 5.x (federation is built into webpack.container; it does not exist in Webpack 4). Use 5.80+ for stable dependOn and runtime handling. Node 18 or 20.
  • @originjs/vite-plugin-federation 1.3.x with Vite 4 or 5 (Rollup 3/4). This plugin is build-mode only — federated remotes are not served through Vite’s native dev ESM pipeline, so you build remotes and vite preview them.
  • For Vite projects that need true dev-mode federation, @module-federation/vite (the official Module Federation 2.0 plugin) is the alternative; it tracks the runtime used by Webpack and Rspack.
  • A working understanding of how exports and interop differ across formats, since federated remotes routinely mix CJS and ESM dependencies. See Understanding ESM vs CommonJS in Modern Bundlers.
Three federation implementations Webpack 5 has built-in ModuleFederationPlugin with dev-server federation; @originjs/vite-plugin-federation is build-only so remotes are served via vite preview; and @module-federation/vite tracks the Module Federation 2.0 runtime with real dev-mode parity. Webpack 5built-in plugindev-server federation vite-plugin-federationbuild-onlyserve via vite preview @module-federation/viteMF 2.0 runtimereal dev parity Pick by whether you need dev-mode federation
Figure: the Vite-native plugin is build-only; reach for @module-federation/vite when you need dev-mode parity.

Core Mechanics: Shared Scope and Runtime Negotiation

A federated build produces a remote entry (remoteEntry.js) and a container. The container exposes the modules listed under exposes and declares the dependencies it is willing to share. At runtime the host calls get() and init(shareScope) on the container; init is where both sides register their shared dependencies into a common object.

In Webpack that object is __webpack_share_scopes__.default, keyed by package name and then by version: { react: { "18.3.1": { get, loaded, from } } }. When a module requests react, the runtime walks the registered versions, applies the requiredVersion semver range, and returns a single resolved instance. singleton: true collapses this to exactly one version for the whole page and downgrades a version conflict to a console warning instead of loading a second copy — essential for stateful libraries like React, where two instances throw Invalid hook call. The mechanics and pitfalls of that specific case are covered in depth in sharing a singleton React instance across remotes.

The resolution is worth spelling out step by step, because every shared-dependency bug is a deviation from it. First, whoever calls init(shareScope) first seeds the registry: the host normally initializes the default scope during its own startup, and each remote’s init merges its shared entries into that same object rather than replacing it. Registration is additive and idempotent — a version already present with loaded: true is not overwritten. Second, when application code actually evaluates an import of a shared package, the runtime does not immediately return a module; it returns a factory that, on first call, selects a version. Selection sorts the registered versions in descending semver order and picks the highest that satisfies the consumer’s requiredVersion. Third, singleton: true changes selection from per-consumer to per-scope: the first satisfying version is loaded, cached under loaded, and every subsequent consumer receives that exact reference regardless of its own range. This is why the order remotes initialize can decide which React version wins when ranges only partially overlap, and why pinning to identical versions is the only way to make the outcome deterministic. Fourth, strictVersion: true promotes a range mismatch from a warning to a thrown error at load time — useful in CI to make an incompatible remote fail loudly instead of silently double-loading.

Three modifiers on each shared entry control that algorithm and are worth memorizing. requiredVersion is the semver range the consumer will accept; leave it to default from package.json unless you are deliberately widening it. singleton forces one instance per scope and is mandatory for anything that holds module-level state — React, react-dom, react-router, most state stores, and any library using Symbol-based identity checks. strictVersion decides whether a mismatch warns or throws. A fourth, version, lets a remote advertise a version string that differs from its installed one, which you almost never want but which occasionally papers over a transitive-dependency lie until it can be fixed properly.

The version-keyed shared scope resolves one instance At init both host and remote register their copies of react into a shared scope keyed by package name and version; when a module requests react the runtime applies requiredVersion and, with singleton true, returns exactly one negotiated instance. host: react@18.3.1 remote: react@^18 shared scopesingleton: true react@18.3.1one copy
Figure: both sides register; singleton: true collapses the registry to one instance and downgrades conflicts to a warning.

eager controls whether the shared module is bundled into the initial chunk (eager: true) or loaded asynchronously the first time it is requested (eager: false, the default). Eager sharing removes the async boundary requirement but inflates the initial payload and is the usual cause of “Shared module is not available for eager consumption”. Lazy sharing keeps payloads small but forces an async entry (a top-level import('./bootstrap') indirection in Webpack). The reason the async boundary is required at all follows directly from the resolution algorithm above: the share scope must be fully seeded before any consumer selects a version, and a synchronous top-level import evaluates the consumer before the scope’s init has run. The import('./bootstrap') indirection buys exactly one microtask of deferral, which is enough for the runtime to register every shared entry before the first require fires.

@originjs/vite-plugin-federation reproduces this contract on top of Rollup output. It rewrites bare imports of shared packages to a runtime shim, emits a __federation_shared_* map, and fetches remote entries with native dynamic import(). Because it operates on the final Rollup graph, its shared negotiation is simpler than Webpack’s — versions are matched but the per-version registry is flatter — which is exactly why behavior diverges. The full side-by-side is in the Webpack vs Vite module federation comparison.

The flatter registry has a concrete consequence you should plan around: the @originjs plugin resolves a shared package to whichever version the host built with, and does not carry the rich per-version fallback table that Webpack’s runtime maintains. In practice that means the host is the source of truth for every shared singleton, and a remote whose installed version drifts outside the host’s range will not transparently load its own copy the way a non-singleton Webpack share might — it will bind to the host’s instance and either work or break on API differences. That is usually what you want for React, and a footgun for utility libraries with breaking changes between minors. When the versions genuinely must differ, the honest options are to stop sharing that package (let each side bundle its own) or to move to @module-federation/vite, whose runtime restores the version-keyed negotiation.

Configuration & CLI Reference

Host and remote configuration contract The remote declares a name, a remoteEntry filename, the modules it exposes, and its shared dependencies; the host declares the remotes it consumes by name and URL plus the same shared dependencies, and consumes the remote lazily behind a Suspense boundary. remotename · filename · exposesshared: react singleton hostremotes: name@URLsame shared + lazy import remoteEntry.js
Figure: the contract is symmetric — both sides list the same shared block; only the remote exposes and the host remotes.

The configuration surface is deliberately symmetric: the remote and the host both declare a shared block, and the two blocks must agree on which packages are singletons and what ranges they accept. The asymmetry is only in the direction of flow — the remote names what it exposes and emits a remoteEntry.js; the host names the remotes it consumes by name@URL. Get the symmetry wrong and the failures are non-obvious: a package listed as shared on the host but not the remote means the remote silently bundles and ships its own copy, defeating the whole point without any error at all. Read the two Webpack configs below as a pair and diff their shared blocks first — they should be identical.

Webpack 5 host and remote

// webpack.config.js — REMOTE (profile app)  // Webpack 5.80+, Node 20+
const { ModuleFederationPlugin } = require("webpack").container;
const deps = require("./package.json").dependencies;

module.exports = {
  mode: "production",
  output: {
    publicPath: "auto", // critical: lets remoteEntry resolve its own chunk URLs
    uniqueName: "profile",
  },
  plugins: [
    new ModuleFederationPlugin({
      name: "profile",
      filename: "remoteEntry.js",
      exposes: {
        "./Card": "./src/components/Card.tsx",
      },
      shared: {
        react: { singleton: true, requiredVersion: deps.react },
        "react-dom": { singleton: true, requiredVersion: deps["react-dom"] },
      },
    }),
  ],
};
// webpack.config.js — HOST (shell app)  // Webpack 5.80+, Node 20+
const { ModuleFederationPlugin } = require("webpack").container;
const deps = require("./package.json").dependencies;

module.exports = {
  mode: "production",
  output: { publicPath: "auto" },
  plugins: [
    new ModuleFederationPlugin({
      name: "shell",
      remotes: {
        // name@URL — the URL must point at the remote's emitted remoteEntry.js
        profile: "profile@http://localhost:3001/remoteEntry.js",
      },
      shared: {
        react: { singleton: true, requiredVersion: deps.react },
        "react-dom": { singleton: true, requiredVersion: deps["react-dom"] },
      },
    }),
  ],
};
// src/App.tsx — HOST: consume the remote lazily with an error boundary
import { lazy, Suspense } from "react";
const RemoteCard = lazy(() => import("profile/Card")); // typed via module declaration

export default function App() {
  return (
    <Suspense fallback={<div>Loading remote…</div>}>
      <RemoteCard />
    </Suspense>
  );
}
// remotes.d.ts — make the federated import type-check
declare module "profile/Card" {
  const Card: React.ComponentType;
  export default Card;
}

The Suspense fallback above handles the loading state but not a failed load — a remote origin that is down, a remoteEntry.js that 404s after a bad deploy, or a chunk that fails integrity. A remote can be redeployed independently of the host, so an unreachable remote is a normal runtime condition, not an exceptional one, and it must degrade to a placeholder rather than unmount the shell. Wrap every remote in an error boundary that catches the rejected dynamic import:

// src/RemoteBoundary.tsx — degrade a dead remote to a placeholder  // React 18.3+
import { Component, type ReactNode } from "react";

export class RemoteBoundary extends Component<
  { fallback: ReactNode; children: ReactNode },
  { failed: boolean }
> {
  state = { failed: false };
  static getDerivedStateFromError() {
    return { failed: true }; // fired when import('profile/Card') rejects
  }
  componentDidCatch(err: unknown) {
    // ship to your telemetry so a remote outage is visible, not silent
    console.error("remote failed to load", err);
  }
  render() {
    return this.state.failed ? this.props.fallback : this.props.children;
  }
}

For anything beyond React, prefer the object form of shared over the array form, because the array form gives you no place to set singleton or strictVersion explicitly. The object form makes the negotiation intent auditable and lets CI fail on a drifted version instead of quietly double-loading:

// webpack.config.js fragment — explicit shared negotiation  // Webpack 5.80+
shared: {
  react: { singleton: true, strictVersion: true, requiredVersion: deps.react },
  "react-dom": { singleton: true, strictVersion: true, requiredVersion: deps["react-dom"] },
  // a stateful store MUST be a singleton or each remote gets its own store
  zustand: { singleton: true, requiredVersion: deps.zustand },
  // a pure utility can safely be non-singleton; let ranges resolve independently
  "date-fns": { requiredVersion: deps["date-fns"] },
},

Vite host and remote with @originjs/vite-plugin-federation

// vite.config.ts — REMOTE (profile app)  // Vite 5.x, plugin 1.3.x
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import federation from "@originjs/vite-plugin-federation";

export default defineConfig({
  plugins: [
    react(),
    federation({
      name: "profile",
      filename: "remoteEntry.js",
      exposes: { "./Card": "./src/components/Card.tsx" },
      shared: ["react", "react-dom"], // array form shares with default singleton-like behavior
    }),
  ],
  build: {
    target: "esnext", // federation requires top-level await support
    minify: false,    // keep export names readable while bringing the wiring up
    cssCodeSplit: false,
  },
});
// vite.config.ts — HOST (shell app)  // Vite 5.x, plugin 1.3.x
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import federation from "@originjs/vite-plugin-federation";

export default defineConfig({
  plugins: [
    react(),
    federation({
      name: "shell",
      remotes: {
        // bare URL to the built remoteEntry.js served by `vite preview`
        profile: "http://localhost:5001/assets/remoteEntry.js",
      },
      shared: ["react", "react-dom"],
    }),
  ],
  build: { target: "esnext" },
});
# Bring up both apps. The plugin is BUILD-ONLY — you cannot use `vite dev` for the remote.
# Remote: build, then preview on a fixed port
npm --prefix ./profile run build
npm --prefix ./profile run preview -- --port 5001 --strictPort   # serves /assets/remoteEntry.js

# Host: build and preview, or run dev against the previewed remote
npm --prefix ./shell run build
npm --prefix ./shell run preview -- --port 5000 --strictPort

Bring-Up Workflow

Six-step federation bring-up Pin shared versions, build and serve the remote first on a fixed port, point the host at that exact remoteEntry URL, consume lazily behind an error boundary, verify react appears once in the shared scope, then lock the contract with a CI smoke test. 1 pin versionsnpm ls 2 serve remotefixed port 3 point hostexact URL 4 lazy boundarySuspense 5 verify scopereact once 6 CI locksmoke test
Figure: serve the remote before the host; step 5 (react resolves once) is the whole point of federation.

Bring federation up in this order specifically because each step makes the next one debuggable in isolation. Skipping ahead — pointing the host at a remote you have not yet confirmed serves valid JS — collapses two independent failures into one confusing symptom.

  1. Pin shared versions. Run npm ls react react-dom in every package and confirm a single resolved version. Mismatches here surface later as duplicate instances, and they surface after the wiring appears to work, which is the worst time to discover them. A hoisted monorepo usually resolves to one copy already; a polyrepo with independent lockfiles does not, so this check is where you catch a remote that installed react@18.2 against a host on 18.3. Fix drift now by aligning the lockfiles, not later by widening requiredVersion.
  2. Build and serve the remote first. Emit remoteEntry.js, then serve it on a fixed port (--strictPort). Confirm curl -I http://localhost:5001/assets/remoteEntry.js returns 200 with content-type: application/javascript (or text/javascript). The --strictPort matters because a plugin that silently increments to the next free port will serve the entry at a URL the host was never told about; you want the serve to fail loudly if the port is taken rather than move. If the content-type comes back text/plain or text/html, the host’s dynamic import will reject before it parses a byte — fix the server MIME mapping before touching any federation config.
  3. Point the host at that exact URL. The remotes value must resolve to the served entry, not a directory. A trailing-path mismatch yields a 404 on first import(). The trap is that @originjs serves the entry under /assets/remoteEntry.js while Webpack serves it at the root, so a URL copied between the two stacks will be off by a path segment. Confirm the exact path with the same curl from step 2, then paste that literal string into remotes.
  4. Consume lazily behind a boundary. Wrap remote imports in React.lazy + Suspense and an error boundary so a remote outage degrades to a fallback instead of a blank shell. This is not optional polish: without the boundary, a single rejected remote import propagates up and unmounts the host, so one team’s bad deploy takes down every other team’s UI on the same page. The RemoteBoundary component above is the minimum; production shells usually add a retry with backoff and a stale-cache fallback.
  5. Verify the shared scope. In the browser console run Object.keys(__webpack_share_scopes__.default) (Webpack) or inspect the __federation_shared_* global (Vite) and confirm react appears exactly once. This is the single most valuable check on the page: it distinguishes “federation is wired” from “federation is working”. If react shows two version keys, or if import * as React from the host and from inside the remote return different object identities (hostReact === remoteReact is false), you have a duplicate instance and hooks will throw the moment the remote renders.
  6. Lock the contract in CI. Add a smoke test that fetches remoteEntry.js and asserts it is non-empty JS, plus a runtime assertion that React resolves to one instance. The contract you are locking is the pair of URLs and the shared version ranges; both drift silently as teams deploy, so an automated check is the only thing that turns a broken integration into a red build instead of a production incident. Run it against a preview deploy of both apps, not against local builds, so it exercises the real cross-origin path.

Debugging & Failure Modes

Four federation failure modes Shared module not available for eager consumption needs an async bootstrap boundary; failed to fetch remoteEntry needs a URL, CORS and content-type check; Invalid hook call means singleton was not honored; and 404 chunks under a nested base need publicPath auto or a base setting. eager consumption error add async import('./bootstrap') boundary failed to fetch remoteEntry check URL, CORS, JS content-type Invalid hook call singleton + overlapping requiredVersion remote chunk 404 (nested base) publicPath: auto / set base
Figure: four symptoms, four fixes — each expands into a subsection below.

Shared module is not available for eager consumption

The symptom is a build-time or first-paint error naming a shared package. The root cause is that a shared dependency is imported synchronously in the entry before the share scope is initialized: the runtime reaches a require for react while the registry is still empty, and because the module was declared shared it refuses to fall back to a static import. The fix is to introduce an async boundary — move app startup into bootstrap.tsx and make the entry a single import("./bootstrap") — so the scope’s init completes in the first microtask before any consumer evaluates. Only set eager: true if you accept the larger initial chunk and genuinely cannot add an async boundary (some server-rendered entry points). Confirm the fix by checking that the entry chunk contains only the import("./bootstrap") call and no direct React import, and that the error is gone on a hard reload.

Failed to fetch dynamically imported module: .../remoteEntry.js

The symptom is a rejected promise on the first render of a remote, with the remote’s URL in the message. The host could not load the remote entry, and there are three independent causes worth checking in order. First, the URL in remotes may not match the served path exactly — re-run the curl -I from the bring-up workflow and paste the literal 200-returning path. Second, the browser may be blocking the cross-origin request: the remote must send Access-Control-Allow-Origin (set server.cors: true and server.origin in Vite, or add CORS headers on the preview/CDN in front of the remote), and a missing header shows up in the console as a CORS error rather than a 404. Third, the response content-type may be text/plain or text/html from a misconfigured proxy or SPA fallback that rewrote the request to index.html — the dynamic import rejects because the body is not a module. Confirm by opening the remoteEntry.js URL directly in a new tab: you should see JavaScript, not an HTML page or a download prompt.

Invalid hook call / two React copies

The symptom is a thrown Invalid hook call the instant a remote component first renders, sometimes accompanied by a warning about multiple copies of React. Two React instances are live because singleton was not honored or requiredVersion ranges did not overlap, so the registry resolved the host and the remote to different instances. Confirm both sides list react and react-dom under shared with singleton: true, and that the resolved versions satisfy each other’s range; then verify at runtime that the host’s React and the remote’s React are the same object reference, not merely the same version string. A subtle variant: a shared package that itself imports React (an icon or UI library) must also be a singleton or it drags in a second copy transitively. The complete reconciliation procedure is in sharing a singleton React instance across remotes.

Remote chunk 404s under a nested base path

The symptom is that remoteEntry.js loads fine but the chunks it then requests 404, usually only in production behind a path prefix or CDN. The root cause is that the remote emitted chunk URLs relative to the wrong base: it hard-coded a base at build time that does not match where it was actually deployed. In Webpack set output.publicPath: "auto", which defers base resolution to the runtime document.currentScript location so the entry computes its own sibling URLs. In Vite set base to the absolute deployment path of the remote (for example /apps/profile/) so remoteEntry.js resolves its sibling chunks against the right prefix. Confirm by loading the deployed host and checking the Network tab: every chunk request from the remote origin should return 200, and none should be prefixed with the host’s base instead of the remote’s.

Remote works locally but stale after deploy

The symptom is that a freshly deployed remote change does not appear in the host, or appears intermittently across users. The cause is caching: remoteEntry.js is the one file that must never be content-hashed, because the host references it by a fixed URL, yet CDNs happily cache it with a long TTL. Serve remoteEntry.js with a short or no-cache header while letting its content-hashed sibling chunks cache forever — the entry is a tiny manifest, so caching it buys nothing and costs you every deploy’s freshness. Confirm by diffing the etag or the hashed chunk names inside remoteEntry.js before and after a deploy; if the host still fetches the old manifest, the cache header is the culprit, not the build.

Performance Impact & Measurement

Federation’s win is payload deduplication, not magic. A correctly shared react + react-dom removes roughly 45 KB gzipped per remote that would otherwise ship its own copy; across four remotes that is the difference between one and four framework copies on the wire. Measure it concretely:

Deduplication is the payoff, at the cost of round-trips Without sharing, four remotes each ship their own React copy; with a shared scope, React is downloaded once across all origins, at the cost of one extra HTTP round-trip per remote entry which modulepreload collapses. unshared 4 remotes → 4 React copies ~45 KB gzip each on the wire shared scope React downloaded once +1 round-trip/entry → modulepreload
Figure: sharing trades one framework download for a small per-entry round-trip you can preload away.
  • Use the Network tab to confirm react-dom is requested once across all origins. A second request for a react-dom chunk from a remote origin is a deduplication failure.
  • Enable build.reportCompressedSize in Vite and diff gzipped remote payloads against a monolithic baseline. Target initial remote payloads under ~150 KB gzipped.
  • Wrap each remote’s import() in performance.mark() / performance.measure() and watch for entries over ~300 ms, which usually mean a cold remote origin or an un-preloaded entry.

The cost side is real: an extra HTTP round trip per remote entry, and lazy shared modules add an async waterfall. The waterfall is the sneaky cost — the host must fetch its own bundle, then fetch remoteEntry.js, then read the manifest, then fetch the exposed chunk, then fetch any shared chunk that was not already loaded. Four sequential round trips on a cold connection is easily 400 ms before the remote paints. Preload critical remoteEntry.js files with <link rel="modulepreload"> in the host’s HTML so the entry fetch overlaps the host’s own bundle download instead of following it:

<!-- host index.html — warm the remote entry in parallel with the host bundle -->
<link rel="modulepreload" href="http://localhost:5001/assets/remoteEntry.js" crossorigin />

Only preload the entries you are confident the first view needs; preloading every remote reintroduces the eager-payload problem you used lazy sharing to avoid. For remotes gated behind a route or an interaction, leave them lazy and let the boundary’s fallback cover the fetch.

When Not to Use Federation

Federation earns its complexity only when independent deployment is a hard requirement — separate teams shipping on separate cadences to the same page. It is the wrong tool for a codebase that deploys as one unit. If every fragment releases together, a monorepo with route-level code splitting gives you the same lazy-loading and payload benefits with none of the runtime negotiation, cross-origin CORS, or version-drift failure modes described above; the module graph stays static and the bundler can tree-shake across the whole app, which federation’s request-time boundary prevents.

Two specific situations argue against it even when you do have multiple teams. First, if the teams cannot agree to pin shared framework versions in lockstep, the singleton negotiation becomes a running source of Invalid hook call incidents, and you are better served by hard iframe isolation where each fragment ships its own everything. Second, if the shared surface is large and changes often — a design system whose API churns weekly — the runtime contract between host and remotes becomes a distributed-versioning problem that a compile-time monorepo would catch at build. Reach for federation when the shared surface is small, stable, and singleton-shaped (the framework, the router, the auth client) and the divergent surface is large; invert that ratio and the costs dominate.

A useful comparison point is native import maps. An import map also lets the browser resolve a bare specifier to a URL at runtime, and for pure ESM libraries with no shared state it is dramatically simpler than federation — no build plugin, no container, no share scope. What import maps do not give you is negotiated singleton resolution across independently built graphs or the exposes/get/init container contract, so they cannot dedupe a stateful React across four separately deployed remotes. Use import maps when you only need URL indirection for stateless dependencies; use federation when you need the runtime to guarantee one instance of a stateful one.

Compatibility Matrix

Federation support across four stacks Webpack 5.80+ and Rspack have built-in federation with dev-mode support; @originjs/vite-plugin-federation is build-only and needs target esnext; @module-federation/vite tracks the MF 2.0 runtime with dev parity. Webpack 5.80+built-in plugin · dev-server federation Rspack 1.xWebpack-compatible · faster builds vite-plugin-federationbuild-only · target: esnext @module-federation/viteMF 2.0 runtime · dev parity
Figure: only the Vite-native @originjs plugin lacks dev-mode federation — the others support it.
Stack Federation mechanism Dev-mode federation Node Notes
Webpack 5.80+ Built-in ModuleFederationPlugin Yes (webpack-dev-server) 18 / 20 Mature share-scope registry; CJS-friendly
Vite 5 + @originjs/vite-plugin-federation 1.3.x Rollup output shim No — build + vite preview 18 / 20 target: esnext required (top-level await)
Vite 5 + @module-federation/vite MF 2.0 runtime Yes 18 / 20 Tracks Webpack/Rspack runtime; better dev parity
Rspack 1.x Built-in MF (Webpack-compatible) Yes 18 / 20 Drop-in for Webpack configs; faster builds

The practical decision reduces to one axis: whether you need federation to work in the dev server, not just in a preview build. If your team’s inner loop depends on hot-reloading a remote while editing the host, the @originjs build-only plugin will frustrate everyone, because every remote change requires a rebuild and a vite preview restart — choose @module-federation/vite or Rspack instead. If federation only needs to hold together in staging and production and the dev loop runs each app standalone, the @originjs plugin is the lightest option and its build-only limitation never bites. Rspack is the low-friction migration target for an existing Webpack federation setup: the config is largely drop-in and the build is materially faster, which matters when every remote’s CI runs a full federated build on each push.

In-Depth Guides