Fine‑grained polyfills with @babel/preset-env and useBuiltIns: usage
Learn how Babel’s preset-env can inject only the polyfills you actually need, based on your browserslist targets, and see the trade‑offs of the usage mode.
07 Oct 2025, 14:26 UTC

Problem: shipping unnecessary polyfills bloats your bundle
When you target a wide range of browsers with Babel, it’s tempting to enable a blanket polyfill import like import 'core-js/stable'. This guarantees that every feature you might use is available, but it also pulls in code for features your application never touches. The result is a larger download for users and wasted parsing time.
Thesis: useBuiltIns: 'usage' lets preset-env inject only the polyfills you actually reference
The useBuiltIns option of @babel/preset-env controls how core‑js polyfills are added. In 'usage' mode, Babel statically analyzes each file, finds references to globals such as Promise, Array.from or Object.entries, and inserts a granular import like import 'core-js/features/promise' only when the target browsers lack that feature. This keeps the bundle close to the size of the code you wrote while still providing the needed shims.
How the analysis works
First, preset-env reads your browserslist query (from package.json, a .browserslistrc file, or an environment variable) to decide which ECMAScript features are already supported. Then, during the transform phase, it walks the AST of each source file. Whenever it encounters an identifier that maps to a core‑js feature, it checks the feature against the target list. If the feature is missing, a corresponding import statement is added at the top of the file.
Worked example: configuring a library for modern browsers with a fallback for IE 11
Suppose you maintain a UI library that ships to browsers covering >0.25% usage, but you also want to support IE 11 for a legacy customer. You can keep a single source tree and let Babel produce two different bundles.
Add the dependencies:
npm i -D @babel/core @babel/preset-env core-js@3Create a
babel.config.jsonthat switches targets based on an environment variable:{ "presets": [ ["@babel/preset-env", { "useBuiltIns": "usage", "corejs": 3, "targets": process.env.BABEL_TARGETS || ">0.25%" }] ] }Build the modern bundle:
BABEL_TARGETS=">0.25%" npx babel src --out-dir dist-modernBuild the IE 11 bundle:
BABEL_TARGETS="ie 11" npx babel src --out-dir dist-ie11
Inspecting dist-modern/utils.js you might see only the imports you actually used, for example:
import "core-js/features/promise";
import "core-js/features/object/entries";
// your transformed code follows
In the IE 11 build, the same file would contain additional imports for features missing in that browser, such as class transforms and Symbol polyfills:
import "core-js/features/symbol";
import "core-js/features/object/define-properties";
// transformed ES5 code
You can verify the granularity by searching the output for import "core-js/" lines and counting how many distinct features appear.
Trade‑off: per‑file import overhead
The 'usage' mode adds an import statement per file that needs a polyfill. If a small utility module uses Promise in many places, you still get only one import for that file, but the total number of import lines can grow with the number of source files. In contrast, 'entry' mode (importing core-js/stable once per entry point) yields fewer import lines but may include polyfills you never use if your entry point pulls in a large dependency tree.
For libraries with many small modules, the extra import lines are usually negligible compared to the savings from avoiding unused polyfills. For applications with a single large bundle, you might prefer 'entry' to keep the import count low, but you should audit the resulting bundle size to ensure you’re not over‑polyfilling.
Practical verification steps
Run the build for your target set.
Open a generated file and look for
import "core-js/"statements.Compare the list of imported features against the features you actually reference in the source (you can grep for
Promise,Array.from, etc.).Optionally, run the bundle through a tool like
webpack-bundle-analyzerto see the proportion of core‑js code in the final output.
If you see imports for features you never use, double-check that your browserslist query is correct and that you haven’t accidentally disabled useBuiltIns.
Action: adopt usage mode for library authors
If you publish a package that consumers will bundle with their own tooling, configure @babel/preset-env with useBuiltIns: 'usage' and a reasonable browserslist default (e.g., 0.25%", "not dead"). Document that consumers can override the targets via environment variables or their own browserslist file without changing your source. This gives you the smallest possible polyfill footprint while still letting downstream projects tailor the output to their support matrix.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.