Module Federation vs Import Maps vs ESM CDN #

You need to load shared code or whole remotes at runtime, and there are three credible ways to do it — and picking wrong means either a bloated runtime, duplicate React copies, or a brittle deploy story.

This guide compares the three approaches head to head: Webpack/Vite Module Federation, native import maps, and ESM-over-CDN (esm.sh, Skypack, jsDelivr’s /+esm). It is a decision tool, not a tutorial — each gets a minimal real example, a dimension-by-dimension table, and concrete recommendations by scenario.

When you reach for runtime composition #

You only need any of these when separate teams must ship and deploy independently and the host must stitch their code together in the browser without a coordinated rebuild. If a single team owns everything and ships together, a monorepo with build-time imports beats all three — don’t pay runtime composition tax you don’t owe.

Once you’ve decided you need runtime composition, the real question is how much machinery you want between your code and the browser’s module loader:

The deeper mechanics of the lightweight end live in Import Maps for Native Module Loading; the heavyweight end is covered in Configuring Webpack Module Federation.

Three runtime composition flows compared Module Federation routes through a bundler runtime that negotiates versions, import maps route bare specifiers through the native browser loader, and ESM CDN fetches pre-built modules from a third party. Module Federation Host imports remote Bundler runtime negotiates versions Shared scope resolves Remote runs Import Maps import 'react' Browser loader maps specifier to URL Single URL, deduped Module runs ESM CDN import from esm.sh Third-party CDN rewrites + serves ESM Deps fetched per URL Module runs
Three loading flows: Module Federation negotiates via a bundler runtime, import maps resolve in the native loader, ESM CDN offloads building and serving to a third party.

The comparison table #

The differences that actually matter on a real project, side by side.

Dimension Module Federation Import Maps ESM CDN
Dependency sharing / dedup Strong: shared scope dedupes at runtime, one instance per version range Strong: one specifier → one URL → one instance, browser-cached Weak by default: each CDN URL can drag its own copy unless you pin
Version control Declarative shared with requiredVersion, singleton, fallbacks You own the map; explicit URLs per version, no negotiation URL-encoded version; CDN resolves transitive ranges for you
Build coupling Tight: host and remotes must agree on shared config and plugin versions Loose: remotes only need to emit bare-specifier ESM Loosest: consumers need no build to use a dependency
Bundler requirement Required (Webpack 5+ or @module-federation/vite) None for resolution; bundler optional for authoring None; works from a plain <script type="module">
Browser support Universal (runtime is shipped JS) Native in all evergreen browsers; es-module-shims for older ones Universal where native ESM works
Caching Per-chunk hashed files; good, but cache keyed to your deploy Excellent: stable CDN URLs cache across apps and deploys Excellent across the public web, but you don’t control invalidation
Type safety Needs generated .d.ts or a types plugin Needs hand-authored ambient declarations for remote URLs Some CDNs serve ?bundle&dts; otherwise manual types
Failure modes Version mismatch, missing shared singleton, eager-load errors 404 on a mapped URL, no fallback negotiation Third-party outage, surprise transitive duplicates, supply-chain risk
DX Rich: hot reload, scope debugging, mature tooling Simple but manual: you maintain the map by hand Frictionless to start; opaque to debug and audit

Approach 1 — Module Federation #

A bundler runtime negotiates which copy of a shared dependency wins. The host declares remotes; both sides declare what they share.

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

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'host',
      remotes: {
        checkout: 'checkout@https://cdn.example.com/checkout/remoteEntry.js',
      },
      shared: {
        react: { singleton: true, requiredVersion: '^18.2.0' },
        'react-dom': { singleton: true, requiredVersion: '^18.2.0' },
      },
    }),
  ],
};
// host/src/bootstrap.js — load the remote at runtime
const { CheckoutWidget } = await import('checkout/Widget');
CheckoutWidget.mount(document.getElementById('slot'));

The runtime guarantees a single React instance across host and remote as long as both satisfy the version range. That negotiation is exactly what Managing Shared Dependencies at Runtime exists to get right.

Approach 2 — Import maps #

The browser’s loader rewrites bare specifiers to URLs. No bundler runtime, no negotiation — the map is the contract.

<script type="importmap">
{
  "imports": {
    "react": "https://cdn.example.com/[email protected]/esm/react.js",
    "react-dom": "https://cdn.example.com/[email protected]/esm/react-dom.js",
    "checkout/": "https://cdn.example.com/checkout/2.1.0/"
  }
}
</script>
<script type="module">
  import React from 'react';                 // one URL, one instance
  const { CheckoutWidget } = await import('checkout/widget.js');
  CheckoutWidget.mount(document.getElementById('slot'), React);
</script>

Because every app on the page maps react to the same URL, the browser fetches and caches one copy — dedup falls out for free. The tradeoff in detail, including when this beats the bundler approach, is in Using Import Maps as a Lightweight Federation Alternative.

Approach 3 — ESM over CDN #

A third-party service builds, rewrites, and serves ESM on demand. Zero infrastructure — you import a URL and go.

<script type="module">
  // esm.sh rewrites transitive deps to esm.sh URLs and serves browser-ready ESM
  import confetti from 'https://esm.sh/[email protected]';
  confetti();
</script>

To avoid duplicate React across several esm.sh imports, pin a shared instance with the ?external (or ?deps) query so the CDN does not bundle its own copy:

// Force every esm.sh module to use the page's single React
import Chart from 'https://esm.sh/[email protected]?external=react,react-dom';

Combining ESM CDN with an import map gives you the best of both: the CDN does the building, the map pins the shared singletons.

Which should you pick #

Match the approach to your constraints, not to fashion.

Pick Module Federation when #

Pick import maps when #

Pick ESM CDN when #

A common production endgame: import maps for your owned shared singletons + Module Federation for genuinely independent team remotes, with ESM CDN reserved for throwaway experiments.

Troubleshooting & gotchas #

Duplicate React (multiple instances) → With Module Federation, set singleton: true and align requiredVersion. With import maps, ensure every app maps react to the identical URL string — a trailing-slash or version difference silently creates two instances. With ESM CDN, add ?external=react,react-dom to every nested import.

Failed to resolve module specifier The import map must be parsed before any module imports run. Inject the <script type="importmap"> in <head> ahead of all type="module" scripts; you cannot add or change a map after the first module loads.

Module Federation version-mismatch warning → A remote requires a version the host’s shared scope can’t satisfy. Either widen requiredVersion, mark it singleton with a strictVersion: false fallback, or accept the second copy if isolation is acceptable.

ESM CDN transitive bloat → Open the network panel: if you see two react URLs from esm.sh, the nested module bundled its own copy. Add ?external or move that dependency into a shared import map.

Stale CDN URL after deploy → Import maps cache aggressively by design. Use content-hashed or versioned URLs so a new release means a new URL — never mutate a file behind a stable path.

FAQ #

Can I mix import maps and Module Federation in the same app?

Yes, and it’s a strong pattern. Use an import map to pin shared singletons (React, your design system) so both the host and federated remotes resolve them to one URL, then let Module Federation handle the genuinely independent remote bundles and their hot reload.

Is ESM CDN safe for production?

For non-critical widgets with pinned versions and ?external singletons, it’s acceptable. For the critical path of an enterprise app, the third-party availability and supply-chain exposure usually outweigh the convenience — self-host the same ESM behind your own CDN and reference it from an import map instead.

Do import maps replace bundlers entirely?

No. They replace runtime resolution, not authoring concerns like tree-shaking, transpilation, or code-splitting heuristics. Most teams still build with a bundler and emit bare-specifier ESM that an import map then wires up at load time.