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.

One Document, Two Bundles, One Execution Both script tags ship in the same HTML; a module-capable browser runs the module script and ignores the nomodule one, while a legacy browser does the reverse. one HTML document both script tags present no server-side detection Module-capable browser runs type="module" — 139.8 KB ignores nomodule by specification Legacy browser runs nomodule — 184.2 KB skips an unknown script type The selection costs nothing: it is how the two script types are specified

Building both variants

Vite ships this as a first-class plugin, which also handles the chunk-manifest separation and the double-download workaround:

One Source, Two Builds The same source is compiled twice with different targets into separate output directories, each with its own chunk manifest. one source tree no per-target code dist/modern/ target es2020 · no polyfills own chunk manifest dist/legacy/ target es5 · polyfills injected own chunk manifest Separate directories are load-bearing: a crossed manifest serves the wrong target
// 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.

Bytes Delivered per Session A single transpiled bundle sends 184 KB to everyone; a dual setup sends 140 KB to modern browsers and 184 KB to the few legacy ones; a modern-only bundle sends 140 KB and drops legacy support. Payload per session (gzipped) Legacy only 184 KB to all Dual bundles 140 KB to ~98% of sessions 184 KB to the rest Modern only 140 KB — legacy unsupported 0 100 KB 200 KB Dual bundles buy the middle row's split; check whether the bottom row is acceptable first

Step-by-step verification

  1. Confirm the modern browser path. Load in a current browser and check the network panel: only the modern bundle should be fetched.

  2. Confirm the legacy path. In a browser without module support, only the legacy bundle should be fetched and executed.

  3. Confirm no double download. On a browser known to fetch both, verify the cleanup script removes the legacy request before it completes.

  4. 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.

  5. Confirm the modern bundle has no polyfills. Search it for core-js source. Any hit means the polyfill injection is applying to both builds.

  6. 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.