Babel preset-env targets: stop transpiling what browsers already run
A tight target query is what keeps @babel/preset-env from rewriting syntax your users already run. Diff two target sets, keep ES modules for the bundler, and verify the result.
09 Sept 2025, 11:40 UTC

If your build rewrites optional chaining into conditional checks for a browser set that has supported it for years, the problem is not Babel itself. @babel/preset-env only applies the transforms its targets require, so a vague target query is what makes it over-transpile. Tightening that query is often the largest reduction in transformed output you can make without touching application code.
The target query is the transform list
Preset-env does not ship a fixed plugin set. It resolves your targets, either from a browserslist config or from an explicit targets option, and enables only the syntax and built-in transforms those environments lack. Change the query and the emitted code changes.
Keep that query in one place so Babel, autoprefixer and any other tool read the same source. A .browserslistrc file at the project root is the simplest option:
# .browserslistrc
last 2 chrome versions
last 2 firefox versions
last 2 safari versionsTo see which browsers a query actually resolves to, run the browserslist CLI from the project root. It only reads config files, so no elevated permissions are required:
npx browserslistIf that list is wider than your real audience, which is a common side effect of copying a default query, every extra old engine pulls transforms into the whole build.
Worked example: diff two target sets
Explicit targets are useful when you want to see the effect of a target change without editing the shared browserslist config. Two small config files make the comparison repeatable.
// babel.config.js
module.exports = {
presets: [
['@babel/preset-env', { targets: { chrome: '120' }, modules: false }],
],
};// babel.legacy.config.js
module.exports = {
presets: [
['@babel/preset-env', { targets: { ie: '11' }, modules: false }],
],
};A one-line source file is enough to show the difference:
// src/example.js
export const label = (user) => user?.profile?.name ?? 'anonymous';Run both configs from the project root, with @babel/cli installed as a dev dependency. Use an output path that exists on your platform rather than the /tmp paths shown here if you are on Windows:
npx babel src/example.js --config-file ./babel.config.js --out-file /tmp/modern.js
npx babel src/example.js --config-file ./babel.legacy.config.js --out-file /tmp/legacy.js
diff /tmp/modern.js /tmp/legacy.jsWhat to look for: the modern output should still contain ?. and ??, because Chrome 120 implements both. The legacy output should replace them with conditional expressions and may add small helper functions near the top of the file. Do not treat any particular helper name as the expected result. Babel's exact output depends on the installed plugin versions, which is precisely why a diff against your own files is a better check than a fixed string.
Both configs set modules: false. That keeps import and export statements intact so a downstream bundler can tree-shake and split code. If Babel converts them to CommonJS first, those optimizations are lost. After a build, check the bundled output for require( calls that originate from your own modules. Their presence means module transformation happened somewhere in the chain.
Runtime features are a separate decision
Transpiling syntax does not add missing built-ins. If your targets lack Promise.allSettled or Array.prototype.flat, preset-env can inject core-js polyfills with useBuiltIns: 'usage' and corejs: 3, adding only what the transformed code references instead of a full polyfill bundle.
The constraint is version alignment. The corejs option must match the installed core-js major version, and combining useBuiltIns: 'usage' with @babel/plugin-transform-runtime carelessly can produce duplicated helpers. Pin both packages and inspect the bundle for repeated imports from core-js after upgrading either one.
Limitations worth planning for
Preset-env output drifts. Browserslist data and Babel plugin versions change between releases, so the same config can emit different transforms after a routine dependency bump. That is not a bug, but it means a lockfile update can change bundle contents. Pin Babel and core-js versions and re-run the diff above after major upgrades.
Individual transforms also accept a loose flag, which emits smaller and faster code by relaxing spec edge cases around classes, getters, setters and property descriptors. It is a genuine trade-off: code that depends on exact descriptor semantics can behave differently. Loose mode is a poor default for a published library and a defensible choice for application code where you understand the relaxation.
What to do next
- Move your target definition into one browserslist source and confirm it with
npx browserslist. - Set
modules: falseif a bundler handles your modules. - Diff a modern target set against your current one using a file that relies on optional chaining and nullish coalescing.
- Check the bundle for duplicated core-js imports and leftover CommonJS wrappers.
- Measure bundle size before and after, and keep the change only if the numbers and your test suite agree.
None of this requires removing Babel. It requires telling Babel who you actually support.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.