Shrinking Polyfill and Transpilation Overhead

A production bundle transpiled for a five-year-old browserslist query routinely ships 80-140 KB of polyfill and helper code that zero real visitors execute. core-js entry-mode imports alone can add 90-160 KB uncompressed; regenerator-runtime tacks on another 3-6 KB; and the transpiled ES5 output for arrow functions, classes, and optional chaining runs 15-30% larger than the native syntax it replaces. None of this is dead code in the tree-shaking sense — every byte executes — but almost all of it is unreachable in practice, because your actual traffic long ago stopped including the browsers it targets.

This is a configuration problem more than a code problem, which makes it unusually cheap to fix once diagnosed. Unlike barrel file elimination, which requires touching import statements throughout a codebase, or CJS-to-ESM conversion, which sometimes requires upstream dependency changes, shrinking polyfill and transpilation overhead is almost entirely a matter of correcting three build settings: the browserslist query, the useBuiltIns mode, and whether the pipeline emits one bundle or a differential pair. None of the application’s source code needs to change.

Where polyfill weight comes from

This problem sits one layer upstream of the tree-shaking and dependency work covered across the Advanced Tree-Shaking & Dependency Optimization pillar. Tree-shaking removes code your application never calls; polyfill and transpilation overhead is code your application does call, but only because Babel assumed a runtime environment that predates native support for the feature. The bundler cannot tree-shake a polyfill for Array.prototype.flat if your source genuinely calls .flat() — the only lever that removes it is telling the transpiler that the target browser already has it built in.

Three inputs jointly determine how much polyfill and transpiled-syntax weight ends up in the bundle:

  1. The browserslist query — the string in .browserslistrc or package.json’s browserslist key that both @babel/preset-env and Autoprefixer read to decide which language and CSS features need a fallback.
  2. The useBuiltIns mode — whether Babel injects a blanket polyfill set ('entry') or a per-usage, per-file polyfill set ('usage') based on static analysis of your actual syntax.
  3. Whether you ship one bundle or two — a single bundle transpiled to the lowest common denominator, or a differential modern/legacy pair that lets evergreen browsers skip the fallback path entirely.

Getting the browserslist query wrong is the highest-leverage mistake here, because it silently controls both Babel’s syntax transforms and any polyfill injection library’s output — a stale query re-inflates the bundle even if useBuiltIns is configured correctly. The dedicated guide to configuring browserslist to drop legacy polyfills walks through auditing and tightening that query directly; this page covers the surrounding build configuration.

The browserslist query also drives Autoprefixer and PostCSS’s preset-env, which means the same stale target that inflates JavaScript polyfills is usually inflating CSS vendor-prefix output too — old -webkit-/-ms- prefixes for flexbox and grid properties that ship to browsers which have supported the unprefixed property natively for years. Tightening the query fixes both problems from a single source of truth, without touching the CSS pipeline directly.

Modern/legacy differential bundle pattern A single application source at the top splits into two build targets. The left path produces a legacy bundle transpiled to ES5 with core-js polyfills and regenerator-runtime, weighing 238 KB, delivered via script nomodule. The right path produces a modern bundle with native syntax and zero polyfills, weighing 96 KB, delivered via script type=module. A browser decision box at the bottom shows evergreen browsers loading only the modern bundle. Application Source one codebase, two targets Legacy Bundle browserslist: IE 11, ES5 core-js polyfills — 132 KB regenerator-runtime — 5 KB Total: 238 KB · script nomodule Modern Bundle browserslist: last 2 years native syntax, no core-js zero regenerator-runtime Total: 96 KB · script type=module Browser picks one at load time evergreen → modern, legacy engines → fallback

Webpack 5 and Vite 5+ configuration side-by-side

useBuiltIns: 'usage' versus 'entry'

@babel/preset-env’s useBuiltIns option controls how core-js polyfills reach your bundle. 'entry' mode requires a single top-of-file import 'core-js/stable' and injects the entire polyfill set matching your browserslist targets — Babel does no per-file analysis, so every polyfill your targets could conceivably need ships regardless of whether your code calls it. 'usage' mode instead parses each file’s actual syntax and injects only the specific polyfills that file’s code exercises, on a per-module basis:

// babel.config.js — shared by Webpack 5 (babel-loader) and Vite 5+ (@vitejs/plugin-legacy)
module.exports = {
  presets: [
    [
      '@babel/preset-env',
      {
        useBuiltIns: 'usage',       // per-file polyfill injection instead of a blanket entry import
        corejs: { version: 3, proposals: false },
        modules: false,             // preserve ES module syntax for the bundler's tree-shaking pass
        bugfixes: true              // ship smaller, more targeted transforms for edge-case syntax
      }
    ]
  ]
};

Setting modules: false matters as much as useBuiltIns here: if Babel transpiles import/export into CommonJS before the bundler sees it, static analysis breaks and the module can no longer participate in sideEffects-driven tree-shaking at all.

Webpack 5 with babel-loader

// webpack.config.js — Webpack 5
module.exports = {
  mode: 'production',
  module: {
    rules: [
      {
        test: /\.m?jsx?$/,
        exclude: /node_modules\/(?!(lodash-es|date-fns)\/)/, // transpile first-party code + select ESM deps
        use: {
          loader: 'babel-loader',
          options: { cacheDirectory: true } // avoids re-transpiling unchanged files on rebuilds
        }
      }
    ]
  },
  optimization: {
    usedExports: true,
    concatenateModules: true
  }
};

Vite 5+ with esbuild + @vitejs/plugin-legacy

Vite’s default dev and build pipeline uses esbuild for syntax transforms, which does not inject core-js polyfills at all — it assumes modern targets. @vitejs/plugin-legacy layers a second, Babel-powered legacy build on top, giving you the differential pattern described below without hand-rolling two Webpack configs:

// vite.config.js — Vite 5+
import { defineConfig } from 'vite';
import legacy from '@vitejs/plugin-legacy';

export default defineConfig({
  build: {
    target: 'es2020' // modern build: esbuild transpiles only what evergreen browsers lack, injects zero polyfills
  },
  plugins: [
    legacy({
      targets: ['defaults', 'not IE 11'],     // legacy build's own browserslist-style query
      additionalLegacyPolyfills: ['regenerator-runtime/runtime'],
      modernPolyfills: false                   // modern build stays polyfill-free; only legacy gets core-js
    })
  ]
});

@vitejs/plugin-legacy emits the modern bundle from esbuild’s fast native transform and a second, fully Babel-transpiled legacy bundle with core-js chunked separately, then injects the <script type="module"> / <script nomodule> pair into your HTML automatically — no manual index.html templating required.

@babel/plugin-transform-runtime versus per-file helper duplication

A second, smaller source of transpilation overhead is unrelated to polyfills entirely: Babel’s syntax transforms (classes, spread, destructuring) each emit a small helper function inline in every file that uses the feature, unless you deduplicate them. @babel/plugin-transform-runtime moves those helpers into a single shared import instead of repeating them per module:

// babel.config.js — Webpack 5 and Vite 5+, added alongside @babel/preset-env
module.exports = {
  presets: [['@babel/preset-env', { useBuiltIns: 'usage', corejs: 3, modules: false }]],
  plugins: [
    ['@babel/plugin-transform-runtime', { corejs: 3, useESModules: true }] // dedupes helpers instead of inlining per file
  ]
};

On a codebase with hundreds of modules using classes or generators, deduplicated helpers typically save 5-10 KB — small next to the core-js number above, but free once the plugin is wired in, and it composes cleanly with the minifier passes covered in eliminating dead code with modern build tools since the shared helper module becomes just another concatenated module in the graph.

Dropping regenerator-runtime for good

regenerator-runtime exists solely to desugar generator functions and async/await for engines that predate native support. Every browser covered by a last 2 years or > 0.5%, not dead browserslist query ships native support, so the correct fix is not a bundler alias — it’s simply never triggering the transform:

// package.json — browserslist key read by @babel/preset-env, Autoprefixer, and @vitejs/plugin-legacy
{
  "browserslist": {
    "production": ["> 0.5%", "last 2 versions", "Firefox ESR", "not dead", "not IE 11"],
    "development": ["last 1 chrome version", "last 1 firefox version"]
  }
}

With this query, @babel/preset-env sees that every production target has native async/await and leaves the syntax untouched — no state-machine transform, no regenerator-runtime import, no injected helper functions. If a transitive dependency still imports regenerator-runtime/runtime directly (common in older CRA-generated code or Babel-transpiled npm packages), remove the explicit polyfill import from your entry file rather than aliasing it away — aliasing to an empty module can crash any code path that still calls a generator function transpiled by an out-of-date dependency.

Framework integration: keeping differential bundles compatible with lazy boundaries

Differential bundling interacts directly with route-level code splitting because every dynamically imported chunk needs to exist in both a modern and a legacy form. React.lazy and Suspense boundaries emit separate chunks per route, and both babel-loader’s Webpack 5 pipeline and @vitejs/plugin-legacy transpile those chunks through the same two-target pipeline as the entry bundle — you do not need separate lazy-loading logic for each target:

// App.jsx — React 18, works unmodified under both the modern and legacy build targets
import { lazy, Suspense } from 'react';

const ReportsPage = lazy(() => import('./routes/ReportsPage.jsx'));

export function App() {
  return (
    <Suspense fallback={<div>Loading…</div>}>
      <ReportsPage />
    </Suspense>
  );
}

The one gotcha: dynamic import() calls inside a legacy build still need a Promise polyfill on genuinely old engines, so useBuiltIns: 'usage' must see the import() call site to inject es.promise correctly — this works automatically as long as Babel processes the file containing the dynamic import, which is why excluding too much of node_modules from babel-loader’s transform (see the pitfalls below) is a common way to break the legacy path specifically. Vue 3 applications using defineAsyncComponent follow the identical rule: the async loader itself is plain JavaScript syntax that both build targets handle without special-casing.

Quantified impact

  • useBuiltIns: 'usage' vs 'entry' — per-file polyfill injection typically cuts core-js weight by 70-90% on applications that use only a handful of modern built-ins (Array.flat, Object.fromEntries, Promise.allSettled), versus the blanket set 'entry' mode ships.
  • Modern browserslist query (last 2 years, not IE 11) vs a default/unset query — removes 60-100% of the transpiled ES5 syntax overhead for arrow functions, classes, and optional chaining, since native syntax passes through untransformed.
  • Differential module/nomodule bundle — evergreen browsers (the overwhelming majority of production traffic in 2025-2026) download only the modern bundle, reclaiming 100-140 KB of polyfill and transpiled-syntax weight versus a single lowest-common-denominator build.
  • Dropping regenerator-runtime — a small but consistent 3-6 KB saving per entry point, compounding across every chunk that contains a transpiled generator or async function under an unnecessarily old target.
  • Combined effect — teams migrating from a five-year-old default browserslist query to an explicit modern target with useBuiltIns: 'usage' and a differential bundle typically see 25-35% of their previous total initial JS payload disappear, layering on top of the reductions available from barrel file elimination and CJS-to-ESM conversion.

Baseline weight breakdown

Before changing any configuration, it helps to know where the bytes actually sit. A representative single-bundle build transpiled against a stale, broad browserslist query breaks down roughly like this on a mid-size React SPA:

Source Typical uncompressed weight Removable with these techniques
core-js/stable (entry mode, full set) 90-160 KB Yes — useBuiltIns: 'usage' + modern target
regenerator-runtime 3-6 KB Yes — modern browserslist query alone
Transpiled ES5 syntax overhead (classes, arrow fns, optional chaining) 15-30% larger than native Yes — modern target/browserslist for the modern bundle
Duplicated Babel helper inlining (no transform-runtime) 5-10 KB Yes — @babel/plugin-transform-runtime
Genuine application logic (unaffected) No — out of scope for this page

The first two rows alone typically account for the bulk of the 80-140 KB figure cited at the top of this page, and both are eliminated by configuration changes rather than code changes — no application logic has to move.

Common pitfalls

Stale .browserslistrc inherited from a scaffolding tool

Failure mode: the bundle still ships full core-js coverage months after the team stopped supporting IE11 or legacy Android WebView. Root cause: scaffolding tools like older Create React App templates write a broad default query (>0.2%, ie 11) that nobody revisits after initial setup. Diagnostic signal: running npx browserslist prints IE11 or and_uc (UC Browser) in the resolved target list. Fix: replace the query explicitly and re-run the dedicated browserslist audit workflow linked above.

useBuiltIns: 'entry' left in place after a modern browserslist update

Failure mode: the browserslist query was tightened but the bundle size barely moved. Root cause: 'entry' mode still injects the entire polyfill set matching the new targets — if the new targets still include even one browser lacking a given built-in, that polyfill ships for the whole application regardless of usage. Diagnostic signal: a single import 'core-js/stable' or import 'regenerator-runtime/runtime' line at the top of the entry file. Fix: switch to 'usage' mode and delete the manual polyfill imports — Babel injects them per file automatically.

Excluding too much of node_modules from transpilation

Failure mode: the legacy bundle throws a syntax error in older browsers, or a dependency’s dynamic import() fails silently. Root cause: a babel-loader exclude: /node_modules/ rule with no exceptions skips transpilation for any npm package shipped as untranspiled modern syntax (common with ESM-only packages). Diagnostic signal: SyntaxError: Unexpected token in a legacy-browser console pointing at a file inside node_modules. Fix: narrow the exclude regex to allow specific ESM packages through, as shown in the Webpack 5 config above.

Treating @vitejs/plugin-legacy as a modern-build performance cost

Failure mode: teams assume adding @vitejs/plugin-legacy slows down every visitor’s load time. Root cause: confusing build-time cost (two transpilation passes) with runtime cost — the plugin’s <script type="module"> / <script nomodule> split means the extra legacy bundle is never fetched by evergreen browsers at all. Diagnostic signal: none at runtime; this is a misdiagnosis, not a real regression. Fix: verify via the Network tab (see below) that only the modern bundle downloads on a current browser before concluding the plugin costs anything in production.

A CDN or CI cache step serving a stale legacy bundle as the default

Failure mode: every visitor, including current-browser traffic, downloads the 238 KB legacy bundle instead of the 96 KB modern one. Root cause: an HTML templating step or edge cache rewrote <script type="module"> into a plain <script> tag, or a build cache served a previous single-bundle HTML shell after @vitejs/plugin-legacy was added. Diagnostic signal: the Network tab shows a nomodule-flagged bundle downloading in an up-to-date Chrome or Firefox release. Fix: purge the HTML shell from any edge or CI cache and confirm the generated index.html contains both type="module" and nomodule script tags with matched, non-stale hashes.

Verification workflow

  1. Resolve the active browserslist query.

    npx browserslist

    Confirm no unsupported legacy engines (IE 11, old Android Browser, UC Browser) appear in the resolved list.

  2. Diff vendor chunk size against a pre-change baseline using whatever bundle analysis tooling your pipeline already runs — a treemap should show core-js and regenerator-runtime shrinking or disappearing entirely from the modern bundle.

  3. Inspect the Network tab on a current browser. Only the type="module" bundle should load; the nomodule legacy bundle must show as not requested at all in Chrome, Firefox, or Safari’s current release.

  4. Force the legacy path in an old engine (or a legacy-mode emulator) and confirm the nomodule bundle loads and the application still functions — this is the regression check that differential builds most commonly skip.

  5. Add a CI budget for the modern bundle specifically, since it is the one nearly all traffic downloads:

    // package.json — size-limit configuration for the modern bundle only
    {
      "size-limit": [
        { "path": "dist/assets/index-*.modern.js", "limit": "90 KB", "gzip": true }
      ]
    }
  6. Re-run webpack --json=stats.json or vite build --logLevel info and grep the module list for core-js entries — a healthy modern build should show zero, and a 'usage'-mode legacy build should show only the specific polyfill modules your code paths actually call, not the full core-js/stable set.

FAQ

What is the difference between useBuiltIns ‘usage’ and ‘entry’ in @babel/preset-env?

'entry' injects a full polyfill set at the top of each entry point based only on your browserslist targets, regardless of which language features your code actually uses. 'usage' scans every file during transpilation and injects only the specific core-js polyfills that file calls, per-module, which typically cuts polyfill weight by 70-90% on modern application code.

Do I still need regenerator-runtime if I target modern browsers?

No. regenerator-runtime exists to desugar generator functions and async/await into ES5-compatible state machines. Every browser in a modern browserslist target (last 2 years, > 0.5% usage, not dead) supports native generators and async/await, so Babel leaves them untranspiled and never injects regenerator-runtime at all.

What is the module/nomodule differential bundle pattern?

It ships two builds from one source: a modern bundle compiled for evergreen browsers with no polyfills, loaded via <script type="module">, and a legacy bundle with full transpilation and core-js coverage, loaded via <script nomodule>. Browsers that understand type=module ignore the nomodule script entirely, so modern users never download legacy polyfill code.

Why does my bundle still include core-js after setting sideEffects: false?

core-js polyfills patch built-ins like Array.prototype or the global Promise constructor, which are genuine side effects. sideEffects: false only controls whether a bundler is allowed to drop unused exports; it does not decide whether a polyfill is needed at all. That decision is made earlier, by @babel/preset-env’s useBuiltIns and browserslist targets, before the bundler’s tree-shaking pass ever runs.