How @babel/preset-env Decides What to Transform — and Where It Can Go Wrong
How @babel/preset-env maps browserslist targets to plugins and polyfills, where the trust boundaries lie, and the three failure modes that reach production — with commands to verify your config today.
27 Oct 2025, 09:05 UTC

The Problem
You add @babel/preset-env to your build, point it at a browserslist query, and expect it to "just work." Most of the time it does — until a polyfill goes missing in production, or your Node 18 service gets shipped with unnecessary async/await regenerator runtime. The preset hides a chain of decisions: which syntax plugins to enable, which polyfills to inject, and whether the target data matches reality. Understanding those boundaries lets you catch drift before it reaches users.
Smallest Suitable Design
A single preset entry with a targets field is all you need for most projects:
// babel.config.js
module.exports = {
presets: [
["@babel/preset-env", {
targets: { browsers: ["last 2 Chrome versions", "Firefox ESR"] },
useBuiltIns: "usage",
corejs: 3
}]
]
};
No per-plugin configuration. The preset reads the browserslist query (from package.json, .browserslistrc, or BROWSERSLIST env var), maps it to a plugin set via @babel/compat-data, and emits two artifact types:
- Syntax transforms — compile-time only, zero runtime cost (e.g., optional chaining → runtime checks).
- Polyfill imports — runtime code from
core-js, controlled byuseBuiltIns.
Override individual plugins only when you have a measured reason (bundle size, specific bug).
Trust Boundary: compat-data vs. Real Browsers
The developer trusts that @babel/compat-data — sourced from MDN compatibility tables — matches actual browser behavior. That data updates roughly monthly via caniuse-lite. If you target "last 2 Chrome versions", the plugin set only refreshes when you upgrade @babel/preset-env (which pulls a newer compat-data). A lag of weeks between a browser release and the preset update is normal.
Risk: compat-data marks a feature as supported, but the browser has a bug or partial implementation. The preset emits native code; your app breaks at runtime with no compile-time warning.
Mitigation: Run integration tests in actual target browsers (Sauce Labs, BrowserStack, or local device lab) in CI. Unit tests in JSDOM or Node do not catch this.
Data Boundary: Transforms vs. Polyfills
useBuiltIns chooses the polyfill injection strategy:
| Mode | Behavior | Trade-off |
|---|---|---|
"usage" | Static analysis scans source for API references (e.g., Array.from), imports only needed polyfills (import "core-js/stable/array/from"). | Smallest bundle; misses dynamic access (obj[prop] where prop === "includes"), eval, Function constructor, or APIs imported from non-analyzed files. |
"entry" | Imports the full core-js stable subset for the targets at the entry point. | Safe for dynamic patterns; larger bundle (often 30–60 KB min+gz extra). |
false | No polyfill injection. You manage core-js imports manually. | Full control; easy to forget a polyfill. |
Pin core-js version in package.json ("core-js": "^3.38.0") — lockfile drift between core-js@2 and @3 entry points is a common source of missing-polyfill bugs.
Operational Checks You Can Run Today
- Resolve targets: Run
npx browserslistin the project root (no elevated permissions). Output shows the concrete browser versions the query resolves to. If the list is empty (e.g.,"dead"or impossible version), CI should fail. - List active plugins: Run
npx babel --show-config(requires@babel/coreinstalled). Thepluginsarray shows every syntax transform preset-env enabled. Compare before/after target changes. - Bundle size diff: Add a CI step that builds with current targets, then with proposed targets, and fails if gzipped size increases beyond a threshold.
- Refresh caniuse data: Run
npx browserslist --update-db(writes tonode_modules/.cache/browserslist). Invalidate CI cache whencaniuse-liteversion changes.
For verbose plugin selection logic, set DEBUG=babel* before the build — output is noisy but definitive.
Failure Modes
1. compat-data false positive
Target includes a browser version that compat-data claims supports a feature, but the browser actually throws. Example: a Chrome version with a broken Intl.Segmenter implementation. Preset emits native code; runtime error in production.
Check: Run the compiled output in the exact target browser version. Automate with WebDriver or Playwright against a browser matrix.
2. "usage" misses dynamic patterns
Code like const method = 'includes'; arr[method](value) or import('./polyfills/' + feature) escapes static analysis. The polyfill is not injected; TypeError: arr[method] is not a function at runtime.
Fix: Add explicit imports for known dynamic patterns (import "core-js/stable/array/includes") or switch to useBuiltIns: "entry" for that package. The size cost is predictable; the debugging cost of a missing polyfill is not.
3. Monorepo target drift
A root browserslist targets browsers, but one package runs in Node 18. Preset-env applies browser plugins to Node code — over-transforming (e.g., transpiling globalThis which Node 18 supports natively) or under-transforming (missing Node-specific polyfills).
Fix: Per-package babel.config.js with overrides or env-specific targets:
// packages/server/babel.config.js
module.exports = {
presets: [
["@babel/preset-env", { targets: { node: "18" }, useBuiltIns: false }]
]
};
Conditions That Change the Design
- New browserslist syntax: When
browserslistadds query forms (e.g.,"node 20"vs"node >=20"), upgrade@babel/preset-envand re-runnpx babel --show-configto verify plugin changes. - Target browser drops a feature: Rare, but if a browser removes support,
compat-dataupdate removes the corresponding plugin. Your next build emits native code for that feature — test the affected paths. - Non-standard runtime: Embedded JS engine (e.g., React Native Hermes, Cloudflare Workers). Write a custom
.browserslistrcfile with a hand-crafted query, then pointBABEL_BROWSERSLIST_CONFIG=/path/to/.browserslistrcat build time.
Limitations & Verification
- Preset-env does not transpile Stage 0/1/2 proposals unless you add the plugins explicitly.
useBuiltIns: "usage"cannot detect APIs loaded viaeval,new Function(), or from files outside the analysis graph.- CI caches often serve stale
browserslistqueries. Invalidate oncaniuse-liteversion change.
Minimal verification script (run in a clean checkout):
# 1. Create test project
mkdir babel-check && cd babel-check
cat > package.json <'EOF'
{
"browserslist": "last 1 Chrome version",
"devDependencies": {
"@babel/core": "^7.24.0",
"@babel/preset-env": "^7.24.0",
"core-js": "^3.38.0"
}
}
EOF
# 2. Install & show config
npm ci
npx babel --show-config # verify plugin list
# 3. Test optional chaining transform
echo 'const x = a?.b;' > input.js
npx babel input.js --out-file output.js --presets=@babel/preset-env
cat output.js # should show transformed code
# 4. Test polyfill injection
echo 'Array.from([1,2])' > input2.js
cat > babel.config.json <'EOF'
{
"presets": [["@babel/preset-env", {"useBuiltIns": "usage", "corejs": 3}]]
}
EOF
npx babel input2.js --out-file output2.js
cat output2.js # should contain import "core-js/stable/array/from"
# 5. Change target to IE 11, confirm transform
sed -i 's/last 1 Chrome version/ie 11/' package.json
npx babel input.js --out-file output-ie.js --presets=@babel/preset-env
cat output-ie.js # should show runtime checks, not native optional chaining
# 6. Refresh data & re-check
npx browserslist --update-db
npx babel --show-config # any plugin changes?
If any step produces unexpected output, the preset configuration or target data needs adjustment before you ship.