Is module/nomodule Differential Serving Still Worth It?

This comparison sits under Polyfills & Differential Serving in JavaScript Bundle Optimization & Code Splitting. The module/nomodule pattern was a clever answer to a 2017 problem: modern browsers understood <script type="module"> and ignored <script nomodule>; old browsers did the opposite. You could ship an ES2017+ bundle to modern browsers and a fully transpiled, polyfilled bundle to everything else, with no user-agent sniffing.

Today, browsers without ES module support are a vanishing share of most sites' traffic. The question is no longer "how do I avoid shipping legacy code to modern browsers?" — a modern-only build does that — but "is it worth maintaining a second build so a tiny legacy audience gets a working site?". The answer depends on who that audience is, and on costs that are easy to underestimate: doubled build time, two sets of chunks to cache and invalidate, test coverage for both, and the odd bugs where the two builds behave differently.

Modern-only build vs differential serving Comparison of shipping a single modern build with shipping modern plus legacy builds via module and nomodule. Modern-only build vs differential serving Modern-only build • One build, one set of chunks • Old browsers get no JavaScript (or a notice) • Simpler caching and testing • Best when legacy share is negligible module + nomodule • Two builds, roughly double build time • Old browsers get a working legacy bundle • Two cache sets, two test matrices • Worth it for a meaningful legacy audience

Rapid Diagnosis: Do You Have a Legacy Audience?

  • Measure, do not guess. In analytics, count sessions from browsers that lack ES module support (very old Safari, Internet Explorer, old Android browsers). For most consumer sites this is well under 0.5%.
  • Check what they do. If those sessions are mostly bots or bounce immediately, they are not customers you are serving well anyway.
  • Check contractual requirements. Enterprise or public-sector contracts sometimes mandate support for specific old browsers.
  • Check what you serve now. If you already use @vitejs/plugin-legacy or a similar setup, quantify its build cost and how often legacy chunks are actually requested.

Root Cause Analysis: The Hidden Costs of Two Builds

1. Build and CI time. A legacy build roughly doubles bundling work, with Babel and polyfill injection usually slower than the modern path.

2. Behavioural drift. Polyfilled and native behaviour occasionally differ (iteration order, Intl formatting, edge cases in Promise or Proxy emulation). Bugs that occur only in the legacy build are hard to reproduce because developers never run it.

3. Double fetch risks. Some older Safari versions downloaded both module and nomodule scripts. Modern browsers do not, but if you preload legacy chunks or misconfigure hints, modern browsers may fetch them anyway.

4. Cache and deploy complexity. Two sets of hashed assets per deploy, plus logic in SSR templates to emit both script tags, add moving parts to every release.

Sessions from browsers without ES module support (sample sites) Bar chart of the share of sessions from non-module browsers across four kinds of sites. Sessions from browsers without ES module support (sample sites) Consumer e-commerce 0.15% Developer documentation 0.02% Public-sector service 0.9% Enterprise intranet app 3.1%

Step-by-Step: Making the Decision

1. Quantify the legacy share and its value

sql
SELECT browser, browser_version, COUNT(*) AS sessions,
       SUM(converted) AS conversions
FROM analytics.sessions
WHERE ts > TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 90 DAY)
  AND NOT supports_es_modules           -- derived from a browser/version lookup table
GROUP BY 1, 2 ORDER BY sessions DESC;
-- trade-off: user-agent strings can be spoofed and bot-generated; filter
-- known bots first or the legacy share will look larger than it is.

Expected outcome: a number and a business value for the audience the legacy build serves.

2. If the share is negligible, ship modern-only with a graceful floor

Serve one modern bundle, make server-rendered content usable without JavaScript, and add a feature-detected message for browsers that cannot run the app.

html
<script type="module" src="/assets/app.4f1c.js"></script>
<script nomodule>
  document.documentElement.classList.add('unsupported-browser');
</script>
<!-- trade-off: users on unsupported browsers see content but not interactivity.
     That is acceptable only if core tasks (reading, contact, checkout fallback)
     do not depend on JavaScript. -->

Expected outcome: one build, a smaller test matrix, and an explicit experience for the rare unsupported visitor.

3. If a real legacy audience exists, use your bundler's legacy plugin

javascript
// vite.config.js
import legacy from '@vitejs/plugin-legacy';
export default {
  plugins: [legacy({ targets: ['defaults', 'not IE 11'], modernPolyfills: false, renderLegacyChunks: true })],
};
// trade-off: modernPolyfills: false keeps the modern bundle free of polyfills;
// set it to true only if your modern targets genuinely lack APIs you use.

Expected outcome: modern browsers keep a lean bundle; legacy browsers receive a separate, polyfilled set.

Differential serving decision Decision sequence for whether to maintain a legacy build alongside the modern bundle. Differential serving decision Contract requires an old browser? Legacy build via the bundler's legacy plugin yes no Legacy sessions above ~1% and converting? Legacy build, tested in CI yes no Core content usable without JS? Modern-only build plus unsupported notice yes no Make core pages work without JS first, then go modern-only

4. Test both builds if you keep both

Run a smoke test of critical flows against the legacy build in CI, in an engine that loads it (forcing the nomodule path), so drift is caught before users find it.

Verification

For modern-only: confirm in RUM that error rates from module-less browsers are understood and that the unsupported notice appears. For differential serving: confirm in the Network panel that modern browsers request only modern chunks, and in CI that legacy smoke tests pass. Either way, compare modern-browser bundle size and TBT before and after to confirm modern users are not paying for legacy support.

Common Mistakes

  • Keeping a legacy build "just in case". Without data, it is easy to maintain a second build for an audience that no longer exists.
  • Assuming nomodule means old browsers will work. If the legacy bundle is never tested, it may not work at all.
  • Using user-agent sniffing instead. Server-side UA detection to choose bundles is fragile and interacts badly with caching (Vary: User-Agent fragments CDN caches); module/nomodule avoids it.
  • Shipping polyfills to modern browsers anyway. A misconfigured legacy plugin can inject polyfills into the modern bundle too; check the modern treemap.

Worked Example: Retiring a Legacy Build

A media site had shipped module/nomodule bundles since 2019. Its analytics showed 0.08% of sessions from browsers that executed the nomodule bundle, almost all with zero-second engagement — consistent with bots and link previewers. The legacy build accounted for 45% of CI build time and had been the source of two production incidents in a year, both caused by polyfill behaviour differences that nobody caught because nobody tested the legacy path. The team removed the legacy plugin, kept server-rendered articles fully readable without JavaScript, and added a small feature-detected notice. Build time dropped by nearly half, the modern bundle was unchanged, and no user-facing complaints followed.

FAQ

Is Internet Explorer still a consideration?

For nearly all public websites, no — it is out of support and its share is negligible. Some internal enterprise environments still run legacy compatibility modes; if you serve them, treat it as a contractual requirement and budget for a legacy build explicitly.

Does differential serving help modern browsers at all anymore?

Its benefit for modern browsers was keeping legacy code out of their bundle. A modern-only build achieves the same for them, so differential serving now exists purely to serve the legacy audience. Judge it on that audience's value, not on modern performance.

What about server-side differential serving by user agent?

It allows finer targeting (several bundles for several engine generations) but forces Vary: User-Agent or edge logic, reducing cache efficiency and adding failure modes. For most sites the marginal bytes saved are not worth it.

How do bots and link previewers fit into the decision?

Many crawlers and preview fetchers identify as old browsers or execute no JavaScript at all. They should be excluded from the legacy-audience count, and the pages they fetch should be server-rendered so they see real content regardless of which bundle runs. A legacy build is never the right way to serve bots.