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 Tool | Babel Integration |
|---|---|
| Webpack | babel-loader |
| Vite | @vitejs/plugin-legacy (uses Babel internally) |
| Rollup | @rollup/plugin-babel |
| Parcel | Built-in Babel support |
| Next.js | Built-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:
| Tool | Language | Speed vs Babel | Notes |
|---|---|---|---|
| Babel | JavaScript | 1x (baseline) | Most mature, most plugins |
| SWC | Rust | 20-70x faster | Used by Next.js, Deno |
| esbuild | Go | 10-100x faster | Used 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:
- Scan your source code for modern features
- Check which of those features your target browsers do not support
- 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
| Value | Behavior |
|---|---|
false | No 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.
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
| Query | Meaning |
|---|---|
> 0.5% | Browsers with more than 0.5% global usage |
> 0.5% in US | Browsers with more than 0.5% usage in the US |
last 2 versions | Last 2 versions of each browser |
not dead | Exclude browsers without official support |
not ie 11 | Explicitly exclude IE 11 |
maintained node versions | All Node.js versions still maintained |
node 18 | Node.js 18 specifically |
chrome >= 90 | Chrome version 90 and above |
since 2022 | All browser versions released since 2022 |
defaults | Shorthand for > 0.5%, last 2 versions, Firefox ESR, not dead |
supports es6-module | Browsers 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:
| Stage | Name | Stability | Babel Support |
|---|---|---|---|
| 0 | Strawperson | Just an idea | Experimental plugins (may change drastically) |
| 1 | Proposal | Problem defined, solution outlined | Experimental plugins (API may change) |
| 2 | Draft | Formal spec text, expected to be included | Plugin available (syntax mostly stable) |
| 3 | Candidate | Spec complete, needs implementation feedback | Plugin available (safe to use) |
| 4 | Finished | Included in next ECMAScript edition | Included 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
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:
- You write modern JavaScript using any syntax and method you want
- Browserslist defines which environments must be supported
- Babel transforms new syntax into older syntax for those environments
- core-js provides polyfills for new methods and APIs
- 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-envintelligently 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.