core-js pure vs. global: how to polyfill without breaking your consumers
Libraries that polyfill globally can break their consumers' environments. Here's how core-js-pure and Babel's runtime transform let you ship modern code without touching shared prototypes.
09 Sept 2025, 22:06 UTC

If you publish a JavaScript library and your build pipeline polyfills Array.prototype.includes or Promise.prototype.finally by mutating globals, you are shipping a side effect your consumers never asked for. Their app may load a different core-js version, rely on a native implementation with subtly different behavior, or feature-detect globals to decide which code path to run. Your "helpful" polyfill can break all three assumptions.
The takeaway: applications should polyfill globally; libraries should not polyfill at all — or should use core-js-pure. This post explains the difference, shows a concrete Babel setup for each case, and covers the trade-offs that make the decision less obvious than it sounds.
What "global" polyfilling actually does
Importing core-js (the default package) patches the environment in place. After import 'core-js/features/array/includes', every array in the process — including arrays created by other libraries — gains a spec-compliant includes method. This is exactly what an application wants: write modern code, target old browsers, done.
The problems appear when more than one actor touches the same globals:
- Version conflicts. If your library bundles core-js 3.x behavior and the host app loads another copy, the last one to run wins. core-js is careful about not overwriting compliant implementations, but "compliant" is judged by its own feature detection, which can disagree with a partial or buggy native implementation.
- Feature-detection breakage. Code like
if (Array.prototype.includes) { /* modern path */ }now takes the modern path because of your polyfill, even in a browser the app author intended to treat as legacy. - Prototype pollution in tests. Consumers snapshot-testing their environment or iterating built-in prototypes will see your additions.
core-js-pure: same implementations, no globals
The core-js-pure package exposes the same polyfilled features as plain imports, without touching any prototype or global. Instead of relying on arr.includes(x), you import the function and call it explicitly:
// Instead of relying on a patched global:
// [1, 2, 3].includes(2);
import includes from 'core-js-pure/features/array/includes';
includes([1, 2, 3], 2); // trueWriting library code this way by hand is tedious, which is why most people let Babel rewrite it. With @babel/preset-env (or @babel/plugin-transform-runtime configured for corejs), Babel rewrites references to modern built-ins into pure imports:
// babel.config.json — for a LIBRARY
{
"presets": [
["@babel/preset-env", {
"useBuiltIns": "usage",
"corejs": { "version": 3, "proposals": false },
"targets": "> 0.25%, not dead"
}]
]
}Wait — that config uses the global core-js. For a library, the cleaner option is the runtime transform, which defaults to non-polluting imports when paired with @babel/runtime-corejs3:
// babel.config.json — library-safe setup
{
"plugins": [
["@babel/plugin-transform-runtime", {
"corejs": { "version": 3, "proposals": false }
}]
]
}With this, source code like new Promise(...) or arr.flat() is rewritten to import the pure implementation from @babel/runtime-corejs3, which you declare as a regular dependency (not a devDependency — it ships in your compiled output). Your published package works in old environments and leaves the global scope untouched.
Worked example: one file, two outcomes
Take this library source:
export function uniquePositive(values) {
return [...new Set(values)].filter(n => n > 0).flat();
}Set, the spread of a Set into an array, and Array.prototype.flat all need polyfills in older environments (e.g., IE11 lacks all three). Build it with the global strategy (useBuiltIns: "usage") and the output starts with imports like core-js/modules/es.array.flat.js — side-effecting modules that patch prototypes for the whole page. Build it with transform-runtime + corejs: 3 and the output instead contains:
import _Set from '@babel/runtime-corejs3/core-js-stable/set';
import _flat from '@babel/runtime-corejs3/core-js-stable/instance/flat';Same behavior for your callers, zero global mutation. You can verify the difference by grepping the built output for core-js/modules (global, side-effecting) versus core-js-pure or runtime-corejs3 (pure imports). A stronger check: load the built file in a test page and confirm Array.prototype.hasOwnProperty('flat') is still false in a browser that lacks it natively.
The trade-offs that complicate the rule
- Pure polyfills can't patch instance behavior invisibly. Babel rewrites syntax it can see. If your code calls a method on an object passed in by the consumer — say,
theirArray.flat()inside a callback — Babel may rewrite it to a pure helper, but only where it can statically detect the call. Dynamic access (obj[method]()) won't be transformed at all. - Duplicated helpers cost bytes. Each pure import pulls in its own copy of the implementation. If the host app also polyfills globally, the same logic ships twice. For small libraries this is trivial; for large ones, run a bundle analyzer and compare.
- "Don't polyfill in libraries" is also valid. Many libraries simply document a minimum environment (or list required polyfills in the README) and ship untransformed modern syntax. That pushes the cost to consumers on legacy targets but keeps your package lean. It's a legitimate choice — just make it explicit.
- Multiple core-js copies still bite with global mode. Even in an app, pin one core-js version at the top level; nested copies from dependencies can produce hard-to-debug "works in dev, breaks in prod" behavior.
A decision you can apply today
Ask one question: do I own the environment my code runs in? If yes (an application, an Electron shell, a controlled embed), use global polyfills via useBuiltIns: "usage" with a browserslist that reflects reality — it's the smallest, simplest option. If no (anything published to npm for others to consume), use @babel/plugin-transform-runtime with corejs: 3, add @babel/runtime-corejs3 as a dependency, and verify the built output contains no core-js/modules imports. Then test once in a genuinely old browser — not just a transpiled-unit-test environment — to confirm the features you rely on actually activate. Polyfill strategy is invisible until it isn't; choosing deliberately is cheaper than debugging someone else's broken prototype chain.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.