Webpack vs Vite Module Federation Comparison: Architecture, Configs & Fixes

Webpack 5 and Vite implement module federation with fundamentally different machinery: Webpack injects a static runtime registry (__webpack_require__.S) at bundle time, while @originjs/vite-plugin-federation shims Rollup output and fetches remotes with native dynamic import(). That difference dictates how shared scope resolves, which errors you hit, and how you debug across boundaries. This guide gives reproducible configs and exact fixes for both; for the runtime sharing model both build on, start from Module Federation and Micro-Frontend Architectures.

The problem federation solves is deployment independence: two teams ship two bundles on two release cadences, and the host stitches them together in the browser without a shared build step. The hard part is that they must still agree on a single copy of React, a single router, and a single design-system runtime, or the page ships two reconcilers and every useContext call reads the wrong provider. Webpack and Vite disagree on when that agreement is enforced. Webpack resolves it while the bundle is being written, baking a version-negotiation table into remoteEntry.js. Vite resolves it while the page is being loaded, negotiating over HTTP as import() calls fire. Neither approach is strictly better, but they fail in opposite directions, and the failure mode you can tolerate should drive the choice.

This matters at a specific point in the build pipeline: after graph construction and chunk splitting, but before hashing and emit. Federation intercepts the moment a bare specifier like react would normally be resolved to a local module, and reroutes it to a shared-scope lookup. Get the interception wrong and you do not get a clean compile error — you get a bundle that builds successfully and then throws at runtime, in production, only when the second remote loads. That asymmetry between build success and runtime failure is why the debugging sections below matter more than the config sections.

Webpack versus Vite federation comparison matrix A two-column matrix contrasting Webpack 5 and Vite federation across architecture, shared scope, dev experience, and tree-shaking. Webpack 5 vs Vite federation Webpack 5 MF Vite plugin-federation Resolution bundle-time registry request-time import() Shared scope versioned registry flat shared map Dev mode dev-server, slower start build + preview only Tree-shaking conservative aggressive (needs sideEffects) Best for legacy CJS monoliths greenfield ESM apps Pick by constraint: runtime maturity vs dev velocity and ESM-native output
Figure: the load-bearing differences between Webpack and Vite federation, row by row.

Architectural Divergence: Bundle-Time vs Dev-Time Federation

Webpack constructs a complete module graph during compilation. It performs AST traversal to identify shared dependencies, hoists them into a shared chunk, and injects a custom runtime registry that intercepts import and require calls. This approach guarantees deterministic chunk boundaries and enables complex runtime sharing, but introduces eager evaluation overhead and tight coupling between host and remote build pipelines. The runtime maintains a global scope object (__webpack_require__.S) that tracks loaded versions, enforces singleton constraints, and resolves version mismatches at execution time.

Vite defers resolution to the browser. During development, the Vite dev server acts as an HTTP proxy, transforming bare module specifiers into absolute URLs and serving native ESM. Production builds use Rollup to pre-bundle and optimize, but the federation mechanism (via @originjs/vite-plugin-federation or @module-federation/vite) relies on standard import() and HTTP fetch rather than a custom runtime. This shifts dependency resolution from compile-time to request-time, aligning with modern browser capabilities and reducing build overhead.

Under the hood, the two runtimes are not comparable in cost. Webpack’s registry is a synchronous object lookup: when a module asks for react, the runtime walks __webpack_require__.S["default"]["react"], picks the entry whose semver range is satisfied, and returns the already-initialized factory. Because that table is populated during an initialization chunk that must run before any exposed module evaluates, the entire shared graph is eagerly wired even if half of it is never rendered. Vite’s plugin instead emits a small shim per shared dependency that awaits a top-level import() — which is exactly why build.target must include top-level await and why the remote cannot run under vite dev, where those shims are never generated. The practical consequence is that Webpack pays its cost up front and predictably, while Vite pays per-remote on first request and can therefore serialize network round-trips if you load remotes sequentially instead of in parallel.

Bundle-time registry versus request-time import Webpack injects a static runtime registry at bundle time that intercepts imports and negotiates versions at execution; Vite defers to the browser, fetching remotes with native dynamic import at request time and isolating modules per origin. Webpack: bundle-time registry __webpack_require__.S at build version negotiation at execution Vite: request-time import() native dynamic import + HTTP fetch modules isolated per origin
Figure: the whole divergence is when resolution happens — bundle time for Webpack, request time for Vite.

Root Cause Analysis: Webpack’s eager shared scope frequently triggers version collisions when multiple remotes request mismatched peer dependencies. The runtime attempts to satisfy the first loaded version, breaking subsequent consumers that expect different major/minor releases. Vite’s native ESM graph isolates modules per origin, eliminating registry collisions but introducing strict CORS and base path alignment requirements. Understanding how modern bundlers abstract dependency resolution and chunk generation is critical when migrating between these paradigms. Refer to Core Concepts of Modern Bundling for foundational mechanics on graph construction, chunk splitting, and runtime injection strategies.

Configuration Paradigm Comparison:

  • webpack.ModuleFederationPlugin operates as a static runtime compiler. It generates remoteEntry.js with explicit chunk hashes and embeds a version negotiation algorithm directly into the bundle.
  • Vite implementations (@originjs/vite-plugin-federation or @module-federation/vite) operate as dynamic dev/proxy transformers. They rewrite import maps at request time, defer shared dependency loading, and rely on native ESM semantics rather than a custom registry.

Exact Configuration Patterns & Reproducible Setups

Side-by-side host/remote configurations require strict alignment of shared scope, version constraints, and ESM compatibility. Misalignment in either bundler results in runtime resolution failures or duplicate dependency injection.

Each bundler's config surface and its signature error Webpack's shared block negotiates versions in the runtime registry and its signature error is can't resolve react in shared scope from a range mismatch; Vite fetches remoteEntry over HTTP and its signature error is failed to fetch dynamically imported module from a CORS or base mismatch. Webpack shared block singleton + requiredVersion err: can't resolve react in scope fix: eager:false, align ranges Vite HTTP fetch remoteEntry over CORS err: failed to fetch module fix: cors:true, align base
Figure: the two bundlers even fail differently — a scope-resolution error on Webpack, a fetch error on Vite.

Webpack 5: Static Shared Scope Resolution

Webpack’s shared configuration dictates how dependencies are hoisted, version-negotiated, and injected into the runtime registry. The plugin evaluates peerDependencies across the monorepo, applies semver constraints, and determines whether to fallback to the remote’s bundled copy or request the host’s instance.

// webpack.config.js (Remote or Host)  // Webpack 5.80+, Node 20+
const { ModuleFederationPlugin } = require("webpack").container;

module.exports = {
  output: { publicPath: "auto" }, // resolve chunk URLs relative to remoteEntry.js
  plugins: [
    new ModuleFederationPlugin({
      name: "remote_app",
      filename: "remoteEntry.js",
      exposes: { "./Component": "./src/Component.tsx" },
      shared: {
        react: { singleton: true, requiredVersion: "^18.0.0" },
        "react-dom": { singleton: true, requiredVersion: "^18.0.0" },
      },
    }),
  ],
};

Setting singleton: true prevents duplicate framework instances by forcing the host to provide the dependency. However, this breaks if requiredVersion mismatches the host’s installed version or if the remote attempts to load before the shared chunk initializes.

Exact Error: Module not found: Error: Can't resolve 'react' in shared scope Root Cause: Mismatched package.json peerDependencies or eager: true forcing synchronous resolution before the shared chunk loads. Webpack’s resolver fails to locate the dependency in the global scope because the host hasn’t registered it, or the remote’s package.json declares a conflicting range. Fix Steps:

  1. Set eager: false on shared dependencies to defer loading until runtime. This prevents synchronous resolution failures during initial chunk evaluation.
  2. Ensure host and remote peerDependencies use identical semver ranges. Run npm ls react across the workspace to verify hoisting consistency.
  3. Add resolve.alias for monorepo symlinked packages to bypass hoisting conflicts and point directly to the resolved node_modules path.

Vite: Native ESM & Plugin Federation

Vite federation relies on HTTP fetch for remote entries. The dev server must correctly expose the remoteEntry.js with appropriate headers, and the host must map remote URLs correctly without path rewriting conflicts. Note that @originjs/vite-plugin-federation is build-only — the remote must be built and served with vite preview, not vite dev.

// vite.config.ts (Remote)  // Vite 5.x, @originjs/vite-plugin-federation 1.3.x
import { defineConfig } from "vite";
import federation from "@originjs/vite-plugin-federation";

export default defineConfig({
  plugins: [
    federation({
      name: "remote_app",
      filename: "remoteEntry.js",
      exposes: { "./Component": "./src/Component.tsx" },
      shared: ["react", "react-dom"],
    }),
  ],
  build: { target: "esnext" }, // top-level await is required by the federation runtime
  server: {
    cors: true,
    origin: "http://localhost:5173",
  },
});

Exact Error: Failed to fetch dynamically imported module: http://localhost:5173/remoteEntry.js Root Cause: Missing CORS headers on the remote dev server or mismatched base configuration causing incorrect asset paths. Vite’s dev server defaults to strict origin policies, and the federation plugin expects exact path alignment between host and remote. Fix Steps:

  1. Enable server.cors: true and explicitly set server.origin in the remote vite.config.ts. This allows cross-origin fetch requests during development.
  2. Align base: '/' across host and remote configurations to prevent path rewriting during fetch. Misaligned bases result in 404s on remoteEntry.js or shared chunk requests.
  3. Verify remoteEntry.js is served with Content-Type: application/javascript. Misconfigured reverse proxies may return text/plain, causing the browser to reject module evaluation.

Micro-Frontend Integration & Shared Dependency Pitfalls

Shared scope behavior diverges significantly in production. Webpack merges shared modules into a single runtime chunk, while Vite maintains them as discrete ESM files. This architectural split directly impacts tree-shaking boundaries, hydration consistency, and concurrent mode compatibility.

When react-dom versions diverge across federated boundaries, Webpack’s global registry triggers hydration mismatches and state leakage. The runtime attempts to reconcile DOM nodes using mismatched reconciler algorithms, resulting in checksum failures and forced client-side rehydration. Vite’s native ESM imports prevent registry collisions but require explicit version negotiation layers to avoid duplicate framework loads across isolated origins. The full procedure for collapsing this to one instance is in sharing a singleton React instance across remotes.

Diverging react-dom breaks differently per bundler Under Webpack a global registry with diverging react-dom versions causes hydration mismatches and state leakage; under Vite isolated origins avoid registry collisions but load duplicate frameworks unless an explicit version negotiation layer is added. Webpack global registry diverging react-dom → hydration mismatch state leakage across boundaries Vite isolated origins no registry collision but duplicate loads without negotiation
Figure: Webpack risks a shared-registry hydration bug; Vite risks a duplicate-load bug — opposite failure shapes.

Root Cause Analysis: Webpack’s runtime shares state via a global registry, causing hydration errors when react-dom versions diverge. Vite’s native ESM imports prevent registry collisions but require explicit version negotiation layers.

Fix Steps:

  1. Pin exact versions in the shared configuration and enable strictVersion: true to fail fast on mismatches. This prevents silent fallbacks to incompatible major versions.
  2. Validate host/remote compatibility by comparing installed React versions across packages before federation initialization: npm ls react in each workspace package.
  3. Use dynamic import() for lazy remote loading to avoid blocking the main thread during initial hydration. Defer remote evaluation until the host’s shared scope is fully initialized.

Step-by-Step Migration & Debugging Workflow

Transitioning from Webpack to Vite federation requires validating production builds, reconciling source maps, and auditing shared chunk boundaries. The migration path must account for differing tree-shaking algorithms, ESM/CJS interop layers, and runtime initialization sequences.

Migration and debugging checkpoints Match build.target to the lowest supported ESM spec, disable minify to isolate scope collisions, audit shared chunks with the visualizer, and align source maps so cross-boundary stack traces resolve to original TSX. build.targetlowest ESM spec minify: falseisolate collisions visualizeraudit shared chunks source mapsalign both sides
Figure: target and minify first isolate the build break; the visualizer and source maps make the runtime break debuggable.

Resolving Production Build Failures

Vite’s default build.target may strip modern syntax required by older remote apps, causing runtime failures after migration. Rollup’s aggressive dead code elimination assumes unused imports are safe to drop, which breaks federated contracts when shared dependencies are incorrectly marked as external.

Exact Error: TypeError: Cannot read properties of undefined (reading 'createElement') Root Cause: Tree-shaking removed a shared dependency due to missing sideEffects: false or incorrect external configuration. Vite’s optimizer drops the dependency from the final chunk, leaving the remote with an undefined reference during component initialization. Fix Steps:

  1. Verify build.target matches the lowest supported browser ESM spec (e.g., esnext or chrome88). Use npx vite build --debug to inspect the Rollup configuration and confirm target transpilation.
  2. Disable minify temporarily (build.minify: false) to isolate scope collisions and verify chunk exports. Minification often obfuscates export names, breaking federation import maps.
  3. Use rollup-plugin-visualizer to audit shared chunk boundaries and confirm react/react-dom are not incorrectly externalized. Run npx vite build with the visualizer plugin and inspect the generated stats.html to verify dependency inclusion.

Debugging Federated Modules in Dev & Prod

Cross-boundary stack traces require aligned source map generation. Webpack uses devtool, while Vite relies on build.sourcemap and native browser mapping. Misaligned source maps obscure the origin of runtime errors, making it difficult to trace failures across host/remote boundaries.

// Vite config  // Vite 5.x
export default { build: { sourcemap: "inline" } };
// Webpack config  // Webpack 5.80+
module.exports = { devtool: "source-map" };

Fix Steps:

  1. Enable “Enable JavaScript source maps” in Chrome DevTools and disable “Pretty Print” to see original TSX/JSX lines. Inline source maps ensure accurate stack traces without external file requests.
  2. Use source-map-explorer on production bundles to verify chunk overlap and identify duplicated dependencies. Run npx source-map-explorer dist/assets/*.js to visualize import graphs and detect shared scope fragmentation.
  3. Add console.trace() in remote entry points (remoteEntry.js or initialization hooks) to isolate initialization order and confirm shared scope injection timing. Trace logs reveal whether the host or remote loads first, preventing race conditions during dependency resolution.

Decision Matrix: When to Use Which

The choice between Webpack and Vite federation depends on project scale, legacy constraints, and team expertise. The following rubric isolates technical trade-offs for build engineers and framework maintainers.

Webpack versus Vite federation decision Choose Webpack for legacy CJS-heavy monoliths needing mature runtime sharing and conservative tree-shaking that preserves federated contracts; choose Vite for greenfield ESM-first apps prioritizing dev velocity, accepting strict CORS and base alignment and explicit sideEffects. choose Webpack legacy CJS-heavy monoliths mature runtime sharing conservative tree-shaking choose Vite greenfield ESM-first apps dev velocity, native ESM strict CORS/base + sideEffects
Figure: runtime maturity and CJS tolerance point to Webpack; dev speed and ESM-native output point to Vite.
Criteria Webpack 5 Module Federation Vite Module Federation
Architecture Static runtime registry (__webpack_require__) Native ESM + Dev-server proxy / Rollup prod
Shared Scope Eager/Lazy hoisting with singleton/requiredVersion HTTP fetch + explicit version negotiation
Dev Experience Slower cold starts, full rebuilds on config changes Fast HMR for app code; federation needs build + preview
Production Single optimized bundle, mature runtime sharing Discrete ESM chunks, requires strict CORS/base alignment
Tree-Shaking Conservative, preserves federated contracts Aggressive, requires explicit sideEffects declarations
Best For Legacy CJS-heavy monoliths, complex runtime sharing Greenfield ESM-first apps, prioritizing dev speed

Implementation Recommendation: Deploy Webpack for legacy CJS-heavy monoliths requiring mature runtime sharing, deterministic chunk boundaries, and complex peer dependency resolution. Adopt Vite for greenfield ESM-first micro-frontends prioritizing developer velocity, native browser compatibility, and reduced build overhead. Before initiating any migration, audit existing peerDependencies, validate sideEffects declarations across the workspace, and establish a strict version negotiation contract across all federated boundaries. Run npx depcheck to identify orphaned shared modules, then incrementally migrate remotes while maintaining a unified CI validation pipeline that asserts remoteEntry.js integrity and shared scope consistency.