Configuring Browserslist to Drop Legacy Polyfills
Symptom: running npx source-map-explorer dist/assets/vendor-*.js shows core-js modules for es.array.flat-map, es.string.pad-start, and a dozen other polyfills your team is certain nobody needs, alongside a regenerator-runtime chunk. npx browserslist prints entries like ie 11, android 4.4.3-4.4.4, and and_uc 15.5 β browsers your analytics dashboard shows zero sessions from in the last twelve months. The vendor chunk is 90-140 KB heavier than it needs to be, purely because of a stale target list nobody has revisited since the project was scaffolded.
$ npx browserslist
ie 11
android 4.4.3-4.4.4
and_uc 15.5
and_qq 13.1
op_mini all
chrome 128
firefox 129
safari 17.5
Four of those eight resolved targets are dead weight for a typical consumer web application in 2025-2026 β but every one of them forces @babel/preset-env and core-js to include fallback code your production visitors never execute.
Root Cause: A Query That Outlived Its Traffic
This is a specific diagnostic scenario within the broader technique of shrinking polyfill and transpilation overhead. The mechanism is direct: browserslist is a shared configuration format read by @babel/preset-env, Autoprefixer, postcss-preset-env, and @vitejs/plugin-legacy. Every one of those tools asks the same question β βwhat is the oldest engine I must support?β β and answers conservatively. If the query includes ie 11 or android 4.4, every tool downstream assumes it must still ship fallbacks for that engine, regardless of how the query got there or whether it reflects real traffic.
Most stale queries trace back to one of three sources: a scaffolding toolβs default (create-react-appβs legacy default was >0.2%, not dead, not op_mini all, which still resolves to a startlingly wide tail of near-extinct browsers), a copy-pasted .browserslistrc from an older internal template, or a query that was correct three years ago and simply was never revisited as the siteβs real traffic shifted toward evergreen browsers. None of these are deliberate decisions β they are defaults that quietly compound with every npm install as caniuse-lite data updates and pulls in new edge-case browsers that technically match a loose percentage threshold.
The fix does not require touching @babel/preset-env, core-js, or any bundler config directly β it is entirely a matter of the browserslist query itself, which every downstream tool already reads. For the surrounding useBuiltIns and differential-bundle configuration that consumes this query, see the parent cluster; for a related dependency-weight problem that is fixed by import statement changes rather than configuration, see replacing lodash with lodash-es for tree-shaking, which addresses a different class of avoidable bundle weight.
Exact Configuration Fix
Option 1 β .browserslistrc file (recommended for standalone tooling like Autoprefixer CLI)
# .browserslistrc β production targets, resolved by Babel, Autoprefixer, and @vitejs/plugin-legacy
[production]
> 0.5%
last 2 versions
Firefox ESR
not dead
not ie 11
not op_mini all
not and_uc > 0
[development]
last 1 chrome version
last 1 firefox version
Option 2 β browserslist key in package.json (recommended when the whole toolchain is npm-script driven)
// package.json β read by @babel/preset-env, Autoprefixer, and @vitejs/plugin-legacy
{
"browserslist": {
"production": [
"> 0.5%",
"last 2 versions",
"Firefox ESR",
"not dead",
"not ie 11",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version"
]
}
}Only one of these two should exist in a given project β if both are present, browserslist throws Browserslist: package.json and .browserslistrc both contain a config. Pick one and delete the other.
The query components matter individually:
> 0.5%β includes any browser version with more than 0.5% global usage share, per thecaniuse-litedataset. Raise this threshold (e.g.> 1%) to be more aggressive about dropping rare engines.last 2 versionsβ the two most recent versions of every browser that has any tracked usage, which keeps you compatible with users who havenβt updated their browser in a release cycle or two.Firefox ESRβ explicitly includes Firefoxβs Extended Support Release, common in enterprise and regulated environments that lag behind the rolling-release channel.not deadβ excludes browsers with no official support or updates for 24 months, the single highest-value exclusion for removing IE-era polyfills.not ie 11,not op_mini allβ explicit denylist entries for engines that technically pass a loose percentage threshold in some regions but that most consumer web applications have no real justification for supporting in 2025-2026.
Verifying the Change with npx browserslist
Resolve the query before and after editing to see the concrete before/after delta:
# Before: broad or default query
npx browserslistie 11
android 4.4.3-4.4.4
and_uc 15.5
and_qq 13.1
op_mini all
chrome 128
chrome 127
firefox 129
firefox 128
safari 17.5
safari 17.4
# After: explicit modern query
npx browserslistchrome 128
chrome 127
firefox 129
firefox 128
firefox 115 # Firefox ESR
safari 17.5
safari 17.4
The resolved list should shrink from a long, IE/legacy-Android-inclusive tail to a short list of current evergreen browser versions plus your explicitly requested ESR channel. If ie 11 or any and_*/op_mini entry still appears, double-check for a competing config file β browserslist resolution order is BROWSERSLIST env var β .browserslistrc β browserslist key in package.json β browserslist.config.js, and an unexpected file earlier in that chain silently overrides your edit.
The useBuiltIns Interaction
Tightening the browserslist query only produces a size reduction if @babel/preset-env is configured to actually consult it per-usage rather than injecting a blanket set:
// babel.config.js β Webpack 5 (babel-loader) and Vite 5+ (@vitejs/plugin-legacy), reads browserslist automatically
module.exports = {
presets: [
[
'@babel/preset-env',
{
useBuiltIns: 'usage', // per-file polyfill injection based on the browserslist query above
corejs: { version: 3, proposals: false },
modules: false
}
]
]
};@babel/preset-env does not need an explicit targets option here β by default it reads the same .browserslistrc or package.json browserslist key that you just edited, so the query change takes effect on the next build without any additional wiring. If your config does set an explicit targets object inside the preset options, that overrides browserslist entirely and the query edit will have zero effect β remove the explicit targets key so the shared config becomes the single source of truth.
Step-by-Step Verification Checklist
-
Resolve the query and confirm no legacy entries remain.
npx browserslistNo
ie,and_uc,and_qq,op_mini, or pre-2020androidentries should appear. -
Clear the Babel and bundler cache before rebuilding, since both
babel-loaderβscacheDirectoryand Viteβsnode_modules/.vitecache can serve stale transpiled output that predates the query change:rm -rf node_modules/.cache/babel-loader node_modules/.vite -
Rebuild and diff the vendor chunk size against a committed baseline:
npx size-limit --compare -
Inspect the polyfill set directly with a treemap analyzer and confirm the
core-jsmodule count dropped β modules likees.array.flat-mapores.string.match-allshould disappear if your targets now natively support them. -
Confirm CSS output changed too. Check
dist/assets/*.cssfor a reduction in-webkit-/-ms-vendor-prefixed rules, since Autoprefixer reads the same query. -
Run the full test suite against a real or emulated legacy engine if any legacy support remains intentional β dropping a browser from the query with no differential fallback means that browser now receives untranspiled modern syntax it cannot parse at all, which is a harder failure than a missing polyfill.
-
Commit a CI check that fails if the resolved browserslist regresses, since a future dependency bump to
caniuse-litecan silently widen the resolved set even with an unchanged query string:# CI step β fail if a legacy engine reappears in the resolved browserslist output npx browserslist | grep -E "ie |and_uc|and_qq|op_mini" && exit 1 || echo "No legacy targets found"
Edge Cases and Gotchas
Gotcha 1 β caniuse-lite drift silently re-widens the query
Even with an unchanged .browserslistrc, running npx update-browserslist-db or a routine npm update can shift which real-world browser versions match a percentage-based query like > 0.5% as global usage data changes. Fix: pin explicit version ranges (last 2 versions, not dead) rather than relying purely on percentage thresholds, and re-run npx browserslist after any dependency update that touches caniuse-lite.
Gotcha 2 β Dropping a browser from browserslist without a differential bundle breaks it harder than a missing polyfill would
If you remove ie 11 from the query but ship a single non-differential bundle, Babel now transpiles to a newer syntax baseline β and any remaining IE11 visitor receives a syntax error, not a missing-feature warning. Fix: either accept that the removed browser is fully unsupported (the common, correct choice for most sites in 2025-2026), or pair the query change with the module/nomodule differential bundle pattern so the legacy engine still gets a working, separately-transpiled bundle.
Gotcha 3 β Monorepo packages with their own browserslist key override the root config
In a monorepo, a shared UI library package can carry its own browserslist key in its package.json, which Babel resolves relative to the file being transpiled β not the application root. If that libraryβs query is stale, its own polyfill footprint is stale even after fixing the applicationβs root config. Fix: audit every package.json in the monorepo for a browserslist key with find . -name package.json -exec grep -l browserslist {} \; and align them, or delete the package-level override so resolution falls through to the shared root query.
Gotcha 4 β A .browserslistrc committed only for a CSS linter, forgotten by JavaScript tooling
Some teams add a .browserslistrc purely to silence an Autoprefixer or Stylelint warning, without realizing @babel/preset-env reads the exact same file. This produces the opposite failure from the others on this page: the CSS output is correctly modern, but nobody checks whether the JavaScript bundle shrank too, because the fileβs purpose in the teamβs mental model was βa CSS thing.β Diagnostic signal: a .browserslistrc exists and looks correct, but core-js polyfills for already-dropped browsers still appear in the JavaScript bundle. Fix: confirm babel.config.js does not set an explicit targets option that bypasses the shared file (see the useBuiltIns section above), and re-run the npx browserslist check from a JavaScript build context, not just a CSS build context, since some monorepo setups scope tool configs to specific workspace packages.
Why This Is the First Fix to Make
Of the three levers covered in the parent cluster β the browserslist query, useBuiltIns mode, and the differential bundle split β the query is the cheapest to fix and the one most likely to have drifted without anyone noticing. It is also independent of the module resolution and dependency graph work a bundler performs β browserslist resolution happens entirely inside Babel and PostCSS, before any bundler-level analysis runs, which is why fixing it never requires touching webpack.config.js or vite.config.js at all. Switching useBuiltIns from 'entry' to 'usage' requires understanding per-file polyfill injection; building a differential bundle requires adding and testing a new plugin. Editing a text file and re-running npx browserslist to confirm the resolved list takes minutes and requires no new tooling. Doing this first also makes every subsequent measurement from the other techniques on this page more accurate, since a stale query would otherwise inflate the baseline that useBuiltIns and differential-bundle changes are measured against.
FAQ
How do I check which browsers my current browserslist query actually targets?
Run npx browserslist from the project root. It reads .browserslistrc, the browserslist key in package.json, or a BROWSERSLIST environment variable (in that resolution order) and prints the full resolved list of browser versions, one per line, that every consuming tool will target.
Should my development browserslist query be the same as production?
No. Most browserslist configs define separate development and production keys. Development should target only the one or two browsers you actually develop in, which speeds up dev-server transpilation. Production should target the real, narrower set of browsers your analytics show visiting the site β not a broad default that assumes decade-old engines.
Why did tightening my browserslist query not shrink the bundle at all?
The most common cause is @babel/preset-env still set to useBuiltIns: 'entry' or missing entirely, which injects a polyfill set independent of per-file usage analysis, or a persistent build cache serving stale transpiled output. Clear the Babel and bundler cache directories and confirm useBuiltIns: 'usage' is active before re-measuring.
Related
- Shrinking Polyfill and Transpilation Overhead β parent cluster covering useBuiltIns modes, differential bundles, and the full polyfill-reduction pipeline
- Advanced Tree-Shaking & Dependency Optimization β top-level pillar this technique extends
- Replacing lodash with lodash-es for Tree-Shaking β a related guide addressing a distinct source of avoidable bundle weight
- Configuring sideEffects for Optimal Tree-Shaking β the adjacent static-analysis contract that governs which modules survive tree-shaking once polyfill weight is under control