Shipping Only the Polyfills You Need: core-js 3 with @babel/preset-env
Configure @babel/preset-env with useBuiltIns: 'usage' and core-js 3 so Babel injects only the polyfills your browserslist targets need — plus how to verify and roll back.
03 Mar 2026, 09:02 UTC

The problem: polyfill bloat you didn't ask for
If you import all of core-js at the top of your app, you ship every ECMAScript polyfill to every browser — including ones that already support the feature natively. The fix is to let Babel inject only the polyfills your browserslist targets actually lack, and only for features your code actually uses. This guide sets that up and shows how to confirm it worked.
Prerequisites
- A project already transpiled with Babel (typically via webpack, Vite's legacy plugin, or a framework CLI).
- Node.js and a package manager; commands below run from the project root with normal user permissions.
- A
browserslistconfiguration, either inpackage.jsonor a.browserslistrcfile. Babel's targeting decisions come entirely from this, so write it deliberately — for example> 0.5%, last 2 versions, not dead.
Install the two packages (core-js is a runtime dependency, preset-env a dev dependency):
npm install core-js@^3
npm install --save-dev @babel/preset-envCheck the installed core-js minor version afterward with npm ls core-js — you will need it in the next step.
Configure preset-env for per-feature injection
In your Babel config (babel.config.json or equivalent):
{
"presets": [
["@babel/preset-env", {
"useBuiltIns": "usage",
"corejs": { "version": "3.38" },
"debug": true
}]
]
}Three things matter here:
useBuiltIns: "usage"tells Babel to scan each file for features likeArray.prototype.flatorPromise.allSettledand insert the matchingcore-js/modules/...imports automatically, filtered by your browserslist targets.corejs.versionmust match your installed core-js minor version. A mismatch produces a Babel warning and can emit wrong or missing imports. Update this string whenever you upgrade core-js.debug: trueprints, at build time, the list of polyfills injected per target. Turn it off once you've verified the setup.
Remove any existing blanket import "core-js"; or import "core-js/stable"; from your entry file — mixing both modes double-includes modules.
Choosing "usage" vs "entry"
| Mode | What it does | When to pick it |
|---|---|---|
usage | Injects per-file imports only for features your source uses and targets lack | Default for applications; smallest output |
entry | Replaces a manual import "core-js/stable" at the entry point with everything the targets lack | When dependencies (not transpiled) also need polyfills, or you want predictable, uniform coverage |
The trade-off: usage only sees your code. Babel does not transpile or inject polyfills into node_modules by default, so a dependency using modern syntax or APIs can still break old browsers. If you rely on such dependencies, either add them to your transpile include list or use entry mode.
Verify the injection actually happened
- Run a production build with
debug: truestill set. Babel logs a table of targets and the core-js modules injected for each. Confirm the list matches features you know you use. - Grep the emitted bundle for a module marker, e.g.
grep -o "es.array.flat" dist/*.js. Presence confirms injection; then check that a feature you don't use is absent. - Compare bundle sizes between
usageandentrybuilds. If they're identical, per-feature injection isn't taking effect — usually a stale Babel cache (clear withnpx babel --clear-cacheor deletenode_modules/.cache) or a leftover global import. - Load the production build in an older target browser (or a device/emulator) and exercise the polyfilled paths. Watch for
undefined is not a functionerrors, which indicate a missing polyfill — often from an untranspiled dependency.
Limits to keep in mind
core-js polyfills language built-ins only. Web APIs — fetch, AbortController, ResizeObserver, IntersectionObserver — need separate polyfills regardless of this setup. Also, the global build patches built-ins on the page; if you're publishing a library to npm, use core-js-pure (same features, no global pollution) so consumers don't inherit your patches. In micro-frontend setups where several apps share a page, conflicting global core-js versions are a real failure mode — coordinate on one version or scope polyfills per app.
Recovery options
Because this change only affects build configuration, rollback is simple: revert the Babel config change and restore any removed entry import, then rebuild. If a usage build misbehaves in production but you can't immediately diagnose it, switching temporarily to entry mode with import "core-js/stable"; is a safe, larger-but-correct fallback while you investigate. Keep core-js updated — compat data and engine fixes ship with each release — but re-check corejs.version in the Babel config every time you bump it.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.