Adding Minimal core-js Stable Polyfills Without Shipping Unstable Proposals
Import core-js's stable entry point once at application startup, let browserslist drive the included features, and avoid unstable proposal polyfills that change behavior between releases.
09 Jan 2026, 18:51 UTC

The problem: polyfills that quietly change underneath you
Legacy browsers fail on modern JavaScript in two ways: syntax they cannot parse, and built-in features they do not have. core-js solves the second problem, but it ships two kinds of polyfills — stable ones that implement finished ECMAScript features, and proposal ones that track in-progress TC39 drafts. If your build pulls in proposal polyfills, a routine dependency upgrade can change runtime behavior, because proposals evolve before standardization. The useful takeaway: import only core-js's stable entry point at the top of your application, let your browserslist decide what actually gets included, and you get deterministic polyfills that only change when the standard does.
What you need before touching the entry file
- A bundler that processes entry imports (webpack, Rollup, Vite, esbuild, or Parcel all qualify) and a package manager to install
core-js. - A
browserslistconfiguration defining your targets, either inpackage.jsonor a.browserslistrcfile. This is what makes the inclusion deterministic — the same targets always produce the same polyfill set for a given core-js version. - core-js version 3.x. The stable/proposal split described here is a core-js 3 design; version 2 has a different layout and is long past maintenance. Pin an exact version or a narrow range in
package.json, because the stable boundary itself can shift between minor releases as proposals graduate. - A decision that this is application code, not a library. Stable entry points mutate globals. If you are publishing an npm package that others consume, skip to the recovery section — you want
core-js-pureinstead.
Procedure: one import at the top of the entry
Install the package:
npm install core-js@^3 regenerator-runtimeRun this in your project root. It writes to package.json and node_modules; no elevated permissions needed. regenerator-runtime is listed separately because transpiled async/await and generator functions call into it at runtime — omitting it produces a ReferenceError: regeneratorRuntime is not defined in older engines even when every other polyfill is present.
Then make these the first two lines of your application entry module (for example src/index.js or src/main.ts):
import 'core-js/stable';
import 'regenerator-runtime/runtime';
// ...the rest of your application importsOrder matters. Polyfills must execute before any application code touches a missing global, and ES module imports are hoisted and evaluated in order, so placing these first guarantees that. Do not wrap them in conditionals or dynamic import() unless you have a deliberate lazy-loading strategy — a static top-level import is the deterministic choice.
Two things to avoid:
- Unstable paths. Never import from
core-js/proposals/...orcore-js/stage/...in production application code. Those modules track drafts and can change semantics between releases. - Importing the whole library blindly alongside Babel's automatic injection. If you also use
@babel/preset-envwithuseBuiltIns: 'usage', Babel injects per-file core-js imports based on the same browserslist. Pick one strategy: either the manual stable import shown here, or Babel'susagemode withcorejs: { version: 3, proposals: false }. Running both duplicates polyfills and inflates the bundle.
Keep module syntax intact for the bundler. If Babel is in the chain, ensure it does not compile ES modules to CommonJS before the bundler sees them (modules: false in preset-env), otherwise tree-shaking and import analysis degrade.
Checking the result
1. Confirm the targets drive the inclusion. Run npx browserslist in the project root to print the resolved target list. If it shows only modern evergreen browsers, core-js stable contributes almost nothing and you may not need it at all; if it includes older engines (for example ie 11 or chrome >= 60), the stable import is doing real work.
2. Verify globals exist in a legacy-like runtime. In a test environment or an actual older browser, before app code runs, assert the features your code uses:
console.log(typeof Promise, typeof Symbol,
Array.prototype.includes, Object.fromEntries);All should be defined. Adjust the list to the features your code actually calls.
3. Inspect the bundle. Use your bundler's analyzer (for example webpack-bundle-analyzer) before and after adding the import. You should see core-js/modules/... entries for stable features only, no core-js/proposals/ paths, and no duplicated regenerator copies.
4. Run the test suite against the target matrix. Execute unit tests in an environment matching your oldest target and confirm no ReferenceError for ES features and that async tests pass.
Recovery and adjustment options
These change state, so each has a straightforward reversal:
- Library or micro-frontend context: global mutation can collide with a host page's own polyfills. Replace the imports with per-feature imports from
core-js-pure(side-effect-free versions you call explicitly) and delete the stable import. Reverting is just restoring the original entry lines. - Bundle too large: narrow
browserslistto drop dead targets, or replacecore-js/stablewith targeted imports such asimport 'core-js/stable/array/includes'after the analyzer shows which modules dominate. Keep the pinned version so the set stays reproducible. - Suspected proposal leakage: search
node_modulesimports and your source forcore-js/proposalsorcore-js/stageand remove them; a lockfile diff plus a fresh install confirms what changed.
Limitations
core-js polyfills built-ins, not syntax — arrow functions, classes, and optional chaining still need a transpiler such as Babel or esbuild for engines that cannot parse them. The stable/proposal boundary is version-sensitive, so treat core-js upgrades as behavior changes and re-run the checks above after each one. Because the stable entry mutates globals, two different core-js versions loaded on one page can conflict; in shared environments, coordinate on a single version. Finally, exact module paths and the set of stable features vary by core-js 3.x minor release, so verify against the version you have pinned rather than assuming this layout is frozen.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.