Skip to main content

How to Handle JavaScript Backward Compatibility: Polyfills, Transpilers, and Babel

JavaScript evolves every year. New syntax, new methods, new features land in the specification annually. But your users do not all run the latest browser. Some are on older phones, corporate machines locked to specific browser versions, or regions where older browsers still dominate. Your code uses optional chaining, nullish coalescing, and Array.prototype.at(), but the browser your user is running has never heard of these features.

This is the backward compatibility problem. You want to write modern, clean JavaScript. Your users need code that works in their environment. The solution is a combination of transpilers (which convert modern syntax into older syntax) and polyfills (which add missing methods and APIs). This guide explains both concepts, shows you how to set up Babel and core-js, and teaches you how to define exactly which environments your code needs to support.

What Are Polyfills and Why You Need Them

A polyfill is a piece of code that implements a feature on browsers that do not natively support it. The term comes from "Polyfilla," a British brand of wall filler. A polyfill fills the gaps in a browser's JavaScript implementation.

The Problem

You write code using a modern method:

const items = ['apple', 'banana', 'cherry'];
const last = items.at(-1); // ES2022 method
console.log(last); // "cherry"

Array.prototype.at() was added in ES2022. Chrome 92+, Firefox 90+, and Safari 15.4+ support it. But if your user runs an older browser, this code crashes:

TypeError: items.at is not a function

The Solution: A Polyfill

A polyfill for Array.prototype.at() checks whether the method exists and adds it if it does not:

// Simplified polyfill for Array.prototype.at()
if (!Array.prototype.at) {
Array.prototype.at = function(index) {
const length = this.length;
const relativeIndex = index >= 0 ? index : length + index;
if (relativeIndex < 0 || relativeIndex >= length) return undefined;
return this[relativeIndex];
};
}

After this polyfill runs, items.at(-1) works in every browser, even those that never implemented the method natively. If the browser already supports at(), the polyfill does nothing because the if check prevents overwriting the native implementation.

What Can Be Polyfilled

Polyfills can add new methods and APIs that are built on top of existing language features:

// These CAN be polyfilled:
Array.prototype.at()
Array.prototype.findLast()
Array.prototype.flat()
Object.entries()
Object.fromEntries()
Promise.allSettled()
Promise.any()
String.prototype.replaceAll()
String.prototype.trimStart()
structuredClone()
globalThis

All of these are methods or functions that can be written using older JavaScript features.

What Cannot Be Polyfilled

Polyfills cannot add new syntax because syntax is processed by the JavaScript parser before any code runs. If the parser encounters syntax it does not recognize, it throws a SyntaxError before any polyfill has a chance to execute:

// These CANNOT be polyfilled:
const x = a ?? b; // Nullish coalescing (syntax)
const y = obj?.prop; // Optional chaining (syntax)
const [a, ...rest] = arr; // Destructuring (syntax)
const fn = () => {}; // Arrow functions (syntax)
class Animal { } // Class declaration (syntax)
let x = 5; // Block-scoped variables (syntax)
for (const item of arr) { } // for...of loop (syntax)
async function fn() { } // Async/await (syntax)

These require a transpiler to convert into older syntax before the code reaches the browser.

The Dividing Line

Can it be implemented as a function/method using older JavaScript?
├── Yes → Polyfill (runtime addition)
│ Examples: Array.prototype.flat(), Promise.allSettled(), Object.entries()

└── No → Transpiler needed (build-time transformation)
Examples: arrow functions, optional chaining, destructuring, async/await

What Is Transpilation? (Babel Explained)

A transpiler (a portmanteau of "transformer" and "compiler") converts source code from one version of a language to another. In JavaScript, the most important transpiler is Babel. It takes modern JavaScript (ES2015+) and converts it into equivalent code that older browsers can understand.

What Babel Does

Babel transforms syntax that older browsers do not understand into equivalent code using older syntax:

Arrow functions (ES2015):

// Before Babel (modern)
const greet = (name) => `Hello, ${name}!`;

// After Babel (ES5 compatible)
var greet = function(name) {
return "Hello, " + name + "!";
};

Optional chaining (ES2020):

// Before Babel
const city = user?.address?.city;

// After Babel
var _user, _user$address;
var city = (_user = user) === null || _user === void 0
? void 0
: (_user$address = _user.address) === null || _user$address === void 0
? void 0
: _user$address.city;

Nullish coalescing (ES2020):

// Before Babel
const name = user.name ?? 'Anonymous';

// After Babel
var _user$name;
var name = (_user$name = user.name) !== null && _user$name !== void 0
? _user$name
: 'Anonymous';

Destructuring (ES2015):

// Before Babel
const { name, age, ...rest } = user;

// After Babel
var name = user.name;
var age = user.age;
var rest = {};
for (var key in user) {
if (key !== "name" && key !== "age") {
rest[key] = user[key];
}
}

Setting Up Babel

Step 1: Install Babel

npm install @babel/core @babel/cli @babel/preset-env --save-dev
  • @babel/core: The core transformation engine
  • @babel/cli: Command-line interface to run Babel
  • @babel/preset-env: A smart preset that determines which transformations to apply based on your target environments

Step 2: Create a Babel configuration file

Create babel.config.json in your project root:

{
"presets": [
["@babel/preset-env", {
"targets": "> 0.25%, not dead"
}]
]
}

Step 3: Add a build script

{
"scripts": {
"build": "babel src --out-dir dist"
}
}

Step 4: Run Babel

npm run build

This reads all JavaScript files from src/, transforms them, and writes the results to dist/.

How Babel Decides What to Transform

With @babel/preset-env, Babel does not blindly transform everything to ES5. It checks your target environments and only transforms features that your targets do not support. If you target Chrome 90+, Babel will leave arrow functions, const, and classes untouched because Chrome 90 supports them natively.

This means the output stays as modern as possible while still being compatible with your targets.

{
"presets": [
["@babel/preset-env", {
"targets": {
"chrome": "90",
"firefox": "88",
"safari": "14"
}
}]
]
}

With these targets, Babel would transform Array.prototype.at() (ES2022, not supported in Chrome 90) but leave let/const and arrow functions alone (supported since Chrome 49).

Babel in Modern Build Tools

In practice, you rarely run Babel directly. It is integrated into build tools:

Build ToolBabel Integration
Webpackbabel-loader
Vite@vitejs/plugin-legacy (uses Babel internally)
Rollup@rollup/plugin-babel
ParcelBuilt-in Babel support
Next.jsBuilt-in (uses SWC by default, Babel optional)

Example with Webpack:

npm install babel-loader --save-dev
// webpack.config.js
module.exports = {
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
},
},
],
},
};

SWC and esbuild: Faster Alternatives

Babel is written in JavaScript and can be slow for large projects. Newer transpilers written in Rust and Go are significantly faster:

ToolLanguageSpeed vs BabelNotes
BabelJavaScript1x (baseline)Most mature, most plugins
SWCRust20-70x fasterUsed by Next.js, Deno
esbuildGo10-100x fasterUsed by Vite, Remix

SWC and esbuild support the same @babel/preset-env style targeting. For new projects, many teams choose these faster alternatives. However, Babel remains the standard for projects that need specific plugins or custom transformations.

Core-js and Polyfill Libraries

While Babel handles syntax transformations, you still need polyfills for new methods and APIs. core-js is the most comprehensive polyfill library for JavaScript.

What core-js Provides

core-js implements polyfills for virtually every ECMAScript feature:

// All of these are polyfilled by core-js
Array.prototype.at()
Array.prototype.findLast()
Array.prototype.findLastIndex()
Array.prototype.flat()
Array.prototype.flatMap()
Array.prototype.toSorted()
Array.prototype.toReversed()
Array.prototype.toSpliced()

Object.entries()
Object.fromEntries()
Object.groupBy()
Object.hasOwn()

Promise.allSettled()
Promise.any()
Promise.withResolvers()

String.prototype.replaceAll()
String.prototype.matchAll()
String.prototype.trimStart()
String.prototype.trimEnd()
String.prototype.at()

structuredClone()
globalThis
AggregateError
// ...and hundreds more

Installing core-js

npm install core-js

Three Ways to Use core-js

Method 1: Import everything (not recommended)

import 'core-js';

// Every polyfill is loaded, even ones you don't use
// Bundle size: ~230KB (uncompressed)

This works but bloats your bundle with polyfills you do not need.

Method 2: Import specific features

import 'core-js/actual/array/at';
import 'core-js/actual/object/group-by';
import 'core-js/actual/promise/all-settled';

// Only the polyfills you explicitly import are included

This is precise but requires you to know exactly which features need polyfilling and to update imports as you add code.

Method 3: Automatic with @babel/preset-env (recommended)

The best approach combines Babel and core-js. Babel analyzes your code, determines which features you use, and automatically includes only the necessary polyfills:

npm install core-js @babel/preset-env --save-dev
{
"presets": [
["@babel/preset-env", {
"useBuiltIns": "usage",
"corejs": "3.37"
}]
]
}

The "useBuiltIns": "usage" setting tells Babel to:

  1. Scan your source code for modern features
  2. Check which of those features your target browsers do not support
  3. Automatically inject only the necessary core-js imports
// Your source code
const last = items.at(-1);
const grouped = Object.groupBy(users, u => u.role);

// Babel automatically adds at the top of the file:
import "core-js/modules/es.array.at.js";
import "core-js/modules/es.object.group-by.js";
// Only the polyfills you actually need!

useBuiltIns Options

ValueBehavior
falseNo polyfills are added (default)
"usage"Adds polyfills only for features you actually use in your code
"entry"Replaces a single import "core-js" with individual imports based on targets

"usage" is the most efficient because it only includes what you need. "entry" is useful if you want all polyfills for your target environments regardless of what your code uses.

Other Polyfill Solutions

regenerator-runtime: Polyfills for async/await and generators. Required when Babel transforms async functions for older browsers:

npm install regenerator-runtime

Babel includes it automatically when needed with @babel/preset-env.

whatwg-fetch: Polyfills the Fetch API for browsers that do not support it (primarily IE11, now largely irrelevant):

npm install whatwg-fetch

Polyfill.io: A service that dynamically delivers only the polyfills needed by the requesting browser:

<script src="https://polyfill.io/v3/polyfill.min.js?features=Array.prototype.at,Promise.allSettled"></script>

The server checks the browser's User-Agent header and returns only the polyfills that specific browser needs. A modern Chrome receives an nearly empty file. An older browser receives the necessary polyfills.

caution

Polyfill.io changed ownership in 2024 and there were security concerns. Many teams now self-host polyfills or use the Babel/core-js approach instead of relying on third-party CDN services for polyfills.

Browserslist and Target Environments

Browserslist is a configuration standard that defines which browsers your project supports. It is used by Babel, Autoprefixer (for CSS), ESLint, and other tools to determine what transformations and polyfills are needed.

Creating a Browserslist Configuration

There are several ways to define your targets. The most common is a browserslist field in package.json:

{
"browserslist": [
"> 0.5%",
"last 2 versions",
"not dead",
"not ie 11"
]
}

Or a separate .browserslistrc file:

> 0.5%
last 2 versions
not dead
not ie 11

Understanding Browserslist Queries

QueryMeaning
> 0.5%Browsers with more than 0.5% global usage
> 0.5% in USBrowsers with more than 0.5% usage in the US
last 2 versionsLast 2 versions of each browser
not deadExclude browsers without official support
not ie 11Explicitly exclude IE 11
maintained node versionsAll Node.js versions still maintained
node 18Node.js 18 specifically
chrome >= 90Chrome version 90 and above
since 2022All browser versions released since 2022
defaultsShorthand for > 0.5%, last 2 versions, Firefox ESR, not dead
supports es6-moduleBrowsers that support ES6 modules

Checking Your Targets

You can see exactly which browsers your configuration resolves to:

npx browserslist

Output:

chrome 120
chrome 119
edge 120
edge 119
firefox 121
firefox 120
ios_saf 17.2
ios_saf 17.1
safari 17.2
safari 17.1
samsung 23
samsung 22
...

Common Configurations

Modern-only (smaller bundles, less transformation):

{
"browserslist": [
"last 2 Chrome versions",
"last 2 Firefox versions",
"last 2 Safari versions",
"last 2 Edge versions"
]
}

Broad compatibility (larger bundles, more transformation):

{
"browserslist": [
"> 0.2%",
"not dead",
"not op_mini all"
]
}

Progressive approach (different targets for different builds):

{
"browserslist": {
"production": [
"> 0.5%",
"not dead",
"not ie 11"
],
"modern": [
"last 2 Chrome versions",
"last 2 Firefox versions",
"last 2 Safari versions"
],
"ssr": [
"node 18"
]
}
}

How Browserslist Connects to Babel

When @babel/preset-env does not have explicit targets, it reads from Browserslist automatically:

{
"presets": [
["@babel/preset-env", {
"useBuiltIns": "usage",
"corejs": "3.37"
}]
]
}
{
"browserslist": [
"> 0.5%",
"last 2 versions",
"not dead"
]
}

Babel reads the Browserslist config, checks which features those browsers support, and applies only the transformations and polyfills needed for the gaps. This creates an optimal balance between modern code and compatibility.

Debugging Compatibility Issues

The @babel/preset-env debug option shows exactly what transformations and polyfills are applied:

{
"presets": [
["@babel/preset-env", {
"useBuiltIns": "usage",
"corejs": "3.37",
"debug": true
}]
]
}

When you run the build, Babel outputs:

@babel/preset-env: `DEBUG` option

Using targets:
{
"chrome": "90",
"edge": "90",
"firefox": "88",
"safari": "14"
}

Using modules transform: auto

Using plugins:
proposal-optional-chaining { safari < 13.1 }
proposal-nullish-coalescing-operator { safari < 13.1 }

Using polyfills with `usage-global` option:
Added core-js polyfill for es.array.at used in src/utils.js
Added core-js polyfill for es.object.has-own used in src/validators.js

This tells you exactly what Babel is transforming and why.

The Relationship Between TC39 Proposals and Babel Plugins

Understanding how new JavaScript features go from proposal to production helps you make informed decisions about which features to use in your code today.

The TC39 Proposal Process

Every new JavaScript feature goes through five stages:

StageNameStabilityBabel Support
0StrawpersonJust an ideaExperimental plugins (may change drastically)
1ProposalProblem defined, solution outlinedExperimental plugins (API may change)
2DraftFormal spec text, expected to be includedPlugin available (syntax mostly stable)
3CandidateSpec complete, needs implementation feedbackPlugin available (safe to use)
4FinishedIncluded in next ECMAScript editionIncluded in @babel/preset-env

Using Proposal-Stage Features

Babel provides plugins for features at various proposal stages. For features not yet in the standard, you install individual plugins:

# Example: a Stage 3 proposal (Decorator metadata)
npm install @babel/plugin-proposal-decorator-metadata --save-dev
{
"presets": ["@babel/preset-env"],
"plugins": [
"@babel/plugin-proposal-decorator-metadata"
]
}

When Features Graduate

When a proposal reaches Stage 4 and is included in the ECMAScript specification, the Babel plugin is merged into @babel/preset-env. You no longer need to install it separately.

For example, optional chaining (?.) went through this journey:

2019: Stage 3 proposal
→ Used via @babel/plugin-proposal-optional-chaining

2020: Stage 4, included in ES2020
→ Automatically handled by @babel/preset-env
→ Separate plugin no longer needed for most configurations

The Risk of Early Adoption

Using Stage 0-2 proposals in production is risky because the syntax or behavior may change:

// The pipeline operator has been at Stage 2 for years
// Its syntax has changed multiple times:

// Original Hack-style proposal
const result = value |> double(%) |> add(%, 1);

// F#-style proposal (different syntax)
const result = value |> double |> add(1);

// If you used one version and the spec adopted the other,
// all your code would need to be rewritten
tip

Safe to use: Stage 3 and Stage 4 features. The spec is essentially finalized and browsers are implementing them. Babel plugins for these are stable.

Use with caution: Stage 2 features. The syntax is mostly stable but may still change.

Avoid in production: Stage 0-1 features. These are experimental and may be withdrawn entirely.

Checking Feature Support

Before deciding whether you need a polyfill or transpilation for a specific feature, check its browser support:

  • Can I Use (caniuse.com): Visual browser support tables for web features
  • MDN Web Docs: Each feature page includes a browser compatibility table
  • node.green: Node.js ES feature support matrix
  • compat-table (kangax.github.io/compat-table): Detailed ECMAScript compatibility

The Complete Modern Setup

Here is a complete configuration that handles both transpilation and polyfills:

// package.json
{
"scripts": {
"build": "babel src --out-dir dist"
},
"browserslist": [
"> 0.5%",
"last 2 versions",
"not dead",
"not ie 11"
],
"devDependencies": {
"@babel/cli": "^7.24.0",
"@babel/core": "^7.24.0",
"@babel/preset-env": "^7.24.0"
},
"dependencies": {
"core-js": "^3.37.0"
}
}
// babel.config.json
{
"presets": [
["@babel/preset-env", {
"useBuiltIns": "usage",
"corejs": "3.37"
}]
]
}

With this configuration:

  1. You write modern JavaScript using any syntax and method you want
  2. Browserslist defines which environments must be supported
  3. Babel transforms new syntax into older syntax for those environments
  4. core-js provides polyfills for new methods and APIs
  5. Only the necessary transformations and polyfills are included

You write modern code. Your users get compatible code. The tools handle everything in between.

Summary

Backward compatibility is a solved problem in modern JavaScript development. Here is how the pieces fit together:

  • Polyfills add missing methods and APIs at runtime. They can implement anything that is expressible using older JavaScript features (like Array.prototype.at(), Promise.allSettled(), Object.entries()).
  • Transpilers convert new syntax into older equivalent syntax at build time. They are needed for features that cannot be polyfilled because they involve syntax the parser must understand (?., ??, =>, async/await, destructuring).
  • Babel is the standard JavaScript transpiler. @babel/preset-env intelligently applies only the transformations your target browsers need.
  • core-js is the most comprehensive polyfill library. With "useBuiltIns": "usage", Babel automatically includes only the core-js polyfills your code actually uses.
  • Browserslist defines your target environments in a single configuration that Babel, Autoprefixer, and other tools share. Use queries like "> 0.5%, last 2 versions, not dead" to specify your support targets.
  • SWC and esbuild are faster alternatives to Babel written in Rust and Go, offering 20-100x speed improvements for large projects.
  • TC39 proposals at Stage 3 and 4 are safe to use with Babel plugins. Stage 0-2 proposals may change and should be avoided in production code.
  • The ideal setup combines Babel (or SWC) with core-js and Browserslist, letting you write the most modern JavaScript possible while automatically ensuring compatibility with your users' browsers.