Stop Transpiling Everything: Let Browserslist Drive Your Babel Build
Hardcoded Babel targets either bloat your bundle or break old browsers. Here's how browserslist-driven preset-env config, core-js injection, and the debug flag turn transpilation into an auditable decision.
27 Jul 2025, 21:26 UTC

Your build ships optional chaining rewritten into nested ternaries for browsers that have supported it natively for years — or worse, it ships the raw syntax to a browser that throws on it. Both failures come from the same place: a Babel config that hardcodes an ECMAScript target instead of asking the question Babel is actually designed to answer — what do my users' browsers support?
The thesis: @babel/preset-env is most useful when you treat it as a negotiation between your source code and a browserslist query, not as a \"compile to ES5\" switch. Get that negotiation right and you ship less code with fewer polyfills. Get it wrong in either direction and you pay in bundle size or in production errors.
Targets decide the transforms, not you
preset-env works from a browserslist query — a shareable string like > 0.25%, not dead that resolves to a concrete list of browser versions using usage statistics and release data. Babel then checks each modern syntax feature in your code against that list and applies only the transforms the targets lack. If every resolved browser supports ?? and ?., those plugins simply don't run.
This is the part teams often miss: the query is the configuration. A config that says \"targets\": {\"esmodules\": true} or pins \"chrome\": \"58\" without a reason is a guess frozen in time. A browserslist query in package.json (or a .browserslistrc file) is a policy statement — \"we support browsers with meaningful usage that still receive updates\" — that tooling across your stack (Babel, Autoprefixer, some bundlers) can share.
Polyfills are a separate decision from syntax
Syntax transforms handle grammar — arrow functions, classes, destructuring. They do nothing for missing APIs like Promise.allSettled or Array.prototype.at. That's core-js's job, and preset-env wires it up via the useBuiltIns option:
useBuiltIns: \"entry\"— you writeimport \"core-js\"once at your app's entry point, and Babel replaces it with only the per-feature imports your targets need. Simple, but it polyfills features your targets lack whether or not your code uses them.useBuiltIns: \"usage\"— Babel scans each file for references to missing built-ins and injects the matching core-js imports automatically. No manual entry import, and you only pay for what you actually call.
A working config looks like this:
// babel.config.json — run builds from the project root;\n// no special permissions needed beyond your normal dev setup.\n{\n \"presets\": [\n [\"@babel/preset-env\", {\n \"useBuiltIns\": \"usage\",\n \"corejs\": 3,\n \"debug\": true\n }]\n ]\n}\n// package.json\n// \"browserslist\": \"> 0.25%, not dead\"\n
The corejs value must match the installed core-js major version — if node_modules has core-js 3, saying 2 (or omitting the option) produces wrong import paths or build errors. Babel also warns if you don't pin the minor (e.g., \"corejs\": \"3.38\"), because polyfill definitions shift between minors. Pin it.
The debugging habit that catches misconfiguration
The debug: true flag above is the most underused feature here. On every build it logs the resolved target list and exactly which transform plugins and core-js imports were applied, per module. That turns an opaque pipeline into something auditable. Two concrete checks worth doing after any config change:
- Read the debug output and confirm the resolved browsers match your intent. If you see Internet Explorer in the list and didn't expect it, your query is broader than you think.
- Grep the built bundle for a feature you know your oldest target lacks — say,
?.— and confirm it was transformed. Then grep for a core-js import you never use; if it's there, youruseBuiltInsmode or targets need a second look.
Compilation succeeding is not verification. The real test is loading the built artifact in your oldest supported browser (or an emulator) and exercising the code paths that use newer APIs.
Trade-offs and the library exception
Three honest caveats. First, reproducibility: browserslist resolves against a moving database (caniuse-lite), so the same query can produce different output after a dependency update. Pin your dependency versions and commit your lockfile if byte-stable builds matter. Second, coverage: preset-env polyfills ECMAScript built-ins only. fetch, IntersectionObserver, and other Web APIs are platform features, not JavaScript — they need separate polyfills regardless of your Babel config. Third, libraries: if you're publishing a package, useBuiltIns: \"usage\" pollutes the host application's globals. Use @babel/plugin-transform-runtime instead, which imports helpers and polyfills from @babel/runtime as module imports rather than mutating the global environment.
What to do this week
Write your support policy as a browserslist query, enable useBuiltIns: \"usage\" with a pinned core-js 3 minor, turn on debug for one build, and read what it tells you. If the resolved targets surprise you, the query was wrong — and now you have a log proving it, instead of a bug report from a user on an old phone.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.