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:
- Module Federation — most machinery: a bundler runtime negotiates shared dependency versions at load time.
- Import maps — least machinery: the browser’s own loader rewrites bare specifiers to URLs.
- ESM CDN — no infrastructure: a third party serves pre-built ESM with dependencies rewritten for you.
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.
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 #
- You ship a large app across many independent teams with strict shared-dependency contracts.
- You need runtime version negotiation (a remote on React 18.2 must safely fall back when the host is 18.3).
- You already run Webpack 5 or Vite with a federation plugin and want hot reload and mature debugging. Start from Configuring Webpack Module Federation.
Pick import maps when #
- You want browser-native loading with no bundler runtime and the smallest possible payload.
- Your shared dependency set is stable and explicit, and you’re comfortable owning the map (often generated in CI).
- You control the CDN that serves your modules, so stable URLs and dedup are reliable.
Pick ESM CDN when #
- You’re prototyping, building a demo, or wiring a low-stakes widget where speed-to-first-import beats control.
- You have a small dependency surface and can pin singletons with
?external. - You explicitly accept the third-party availability and supply-chain tradeoff — avoid it for the critical path of production enterprise apps.
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.