Shipping Modern and Legacy Bundles With module/nomodule
Every transpiled bundle carries a tax that most of its users do not owe. async/await compiled to a generator state machine, classes rewritten as constructor functions, destructuring expanded into temporary variables, and a set of polyfills for methods the browser has supported for years — all so the small share of sessions on an old engine can run the same code.
The measurement is straightforward:
dist/legacy/index-9c31de.js 184.2 kB gzipped (target: browsers >= 2018)
dist/modern/index-4f2a91.js 139.8 kB gzipped (target: browsers with module support)
────────────
44.4 kB, 24%, paid by everyone
The module/nomodule pattern lets each browser class receive only what it needs, using a selection mechanism the browser implements natively. It complements the build-time narrowing described in shrinking polyfill and transpilation overhead: instead of choosing one target for everyone, you ship two and let the browser choose.
Root cause: one output must satisfy the oldest supported browser
A bundler applies one target configuration to one output. Set the target to include a browser without native modules, and every transform required by that browser applies to the whole bundle — for every visitor.
The selection mechanism is a specification detail with useful consequences: a browser that supports ES modules executes <script type="module"> and ignores any script carrying the nomodule attribute. A browser that does not support modules ignores the module script (it does not recognise the type) and executes the nomodule one. No feature detection, no runtime cost.
Building both variants
Vite ships this as a first-class plugin, which also handles the chunk-manifest separation and the double-download workaround:
// vite.config.js — Vite 5+
import legacy from '@vitejs/plugin-legacy';
export default {
plugins: [
legacy({
// Only browsers matching this get the transpiled build.
targets: ['defaults', 'not IE 11'],
// Polyfills are injected into the LEGACY bundle only — the modern
// bundle ships none, which is where most of the saving comes from.
modernPolyfills: false,
renderLegacyChunks: true,
}),
],
build: {
target: 'es2020', // the modern bundle's floor
},
};With Webpack the two builds are two configurations sharing one source, differing only in target and output directory:
// webpack.config.js — Webpack 5: two configs, one source
const base = require('./webpack.base');
module.exports = [
{
...base,
name: 'modern',
output: { ...base.output, path: `${__dirname}/dist/modern` },
target: ['web', 'es2020'],
module: { rules: [{ test: /\.jsx?$/, use: { loader: 'babel-loader',
options: { targets: { esmodules: true }, useBuiltIns: false } } }] },
},
{
...base,
name: 'legacy',
output: { ...base.output, path: `${__dirname}/dist/legacy` },
target: ['web', 'es5'],
module: { rules: [{ test: /\.jsx?$/, use: { loader: 'babel-loader',
options: { targets: '> 0.5%, last 2 versions', useBuiltIns: 'usage', corejs: 3 } } }] },
},
];Separate output directories are not cosmetic. Each build emits its own chunk manifest, and a runtime that loads a chunk from the wrong manifest gets a file compiled for the wrong target — which fails in ways that look like the ChunkLoadError family but are not.
Emitting the tags, and the double-download fix
<!-- Modern browsers execute this and ignore the next one -->
<script type="module" src="/dist/modern/index-4f2a91.js"></script>
<!-- Legacy browsers execute this and ignore the previous one -->
<script nomodule defer src="/dist/legacy/index-9c31de.js"></script>
<!-- A few older browsers fetch BOTH. Remove the legacy tags when modules work. -->
<script type="module">
// Runs only where modules are supported, before the nomodule fetch completes.
for (const el of document.querySelectorAll('script[nomodule]')) el.remove();
</script>The cleanup script matters in proportion to the legacy bundle’s size: on the affected browsers, without it, users download both variants and execute one, which is strictly worse than shipping a single bundle.
When not to do this
The pattern doubles build time, doubles the artefacts to deploy and cache, and doubles the surface where a target-specific bug can hide. It is worth it only when a meaningful share of traffic actually needs the legacy build.
Check the real numbers before adopting it. If the analytics show that essentially every session supports modern syntax, the better answer is to delete the legacy build entirely and narrow the target, as described in configuring Browserslist to drop legacy polyfills — one bundle, no selection logic, and the same saving for the overwhelming majority.
Step-by-step verification
-
Confirm the modern browser path. Load in a current browser and check the network panel: only the modern bundle should be fetched.
-
Confirm the legacy path. In a browser without module support, only the legacy bundle should be fetched and executed.
-
Confirm no double download. On a browser known to fetch both, verify the cleanup script removes the legacy request before it completes.
-
Confirm chunk manifests do not cross. Navigate to a lazily-loaded route in each browser class and check that its chunk comes from the matching output directory.
-
Confirm the modern bundle has no polyfills. Search it for core-js source. Any hit means the polyfill injection is applying to both builds.
-
Measure the real split. Report which bundle each session received and compare against your browser analytics — see measuring real-user chunk loading performance. If the legacy share is near zero, remove the legacy build.
Edge cases and gotchas
Module scripts are deferred by default. A module script does not block parsing, whereas a classic script without defer does. Add defer to the nomodule tag so both paths have comparable ordering semantics.
Modules are always fetched in CORS mode. Cross-origin module scripts need the appropriate headers and a matching crossorigin attribute — the same mismatch trap described in fixing unused preload warnings in Chrome DevTools.
Inline scripts in the legacy path. Any inline script written in modern syntax throws in the legacy browser regardless of which bundle it loaded. Transpile inline scripts too, or keep them to the simplest possible syntax.
Doubling the deploy surface. Two builds means two sets of hashed files to retain across releases, which doubles the storage cost of the retention window that prevents stale-manifest errors.
FAQ
How much smaller is a modern bundle in practice?
Typically 15–25% of application code, and more once polyfills are excluded. The saving comes from not downgrading syntax the browser already understands: async/await stops expanding into state machines, classes stay classes, and destructuring, optional chaining and spread all survive as written. Combined with dropping the polyfills a modern target no longer needs, the difference is frequently 30–40% of the total payload.
Is module/nomodule still worth it?
It depends entirely on how much traffic actually needs the legacy build. If the analytics show effectively none, the simpler and better answer is to drop the legacy build and ship modern syntax to everyone. The pattern earns its complexity only when a meaningful share of sessions genuinely requires the fallback — an enterprise product with managed devices, or a market where older browsers remain common.
Why do some browsers download both bundles?
Because a handful of older browser versions understand the module attribute well enough to fetch the module script but do not implement nomodule, so they fetch that one as well. They execute only the modern one, but they pay for both downloads. A small inline script that removes the legacy script elements when module support is detected eliminates the wasted transfer, and it is worth including whenever the legacy build is large.
Related
- Shrinking Polyfill and Transpilation Overhead — the parent guide on transpilation cost
- Configuring Browserslist to Drop Legacy Polyfills — the single-bundle alternative, and usually the first thing to try
- Conditionally Importing Polyfills at Runtime — a runtime alternative to a second build
- Measuring Real-User Chunk Loading Performance — measuring which bundle real sessions receive