Transpilers & polyfills
Learn how Babel, SWC, Browserslist, Baseline, feature detection, and core-js let modern JavaScript run safely in older browsers.
- 01Name the gapExplain why the code you write may not run in your users' browsers.
- 02Choose the right fixDecide whether a problem needs a transpiler, a polyfill, a ponyfill, a feature check, or a fallback.
- 03Use the toolsUse Browserslist, Baseline, Babel, SWC, TypeScript, and core-js, and know what each one can't fix.
New code, old browsers
JavaScript gets new features every year. But many people still use older browsers. They use them on old phones, work laptops, and smart TVs. Some use them inside apps that show web pages; these built-in browsers are called WebViews.
An older browser may not understand your newest code. This lesson shows the tools that close that gap.
A transpiler is a tool that rewrites your code into older JavaScript before the browser sees it. It reads your code, changes it, and writes new code.
A polyfill is a small piece of code that adds a missing feature while the page runs. It only helps when the browser can already read the code around it.
So the rule is short:
- New syntax needs a transpiler. Syntax is the way code is written, like the
?.sign. - A missing API needs a polyfill or a ponyfill. An API is a built-in function or object, like
Array.prototype.at. A ponyfill is a helper function you call yourself; you'll see one below. - Some features change how the engine works. The engine is the part of the browser that runs JavaScript. No tool can add those features to an old browser. You need a different design, or you stop supporting that browser.
The table below compares four tools. Read each row as: "this tool changes this, for example like this."
| Tool | What it changes | An example |
|---|---|---|
| Transpiler | It rewrites your code into older syntax before users download it. | It turns items?.[0] ?? fallback into older checks that an old browser can read. |
| Polyfill | It adds a missing built-in feature while the page runs. It usually adds it to a shared object, like Array.prototype. | It adds Array.prototype.at when an old browser doesn't have it. |
| Ponyfill | It gives you a helper function to call. It doesn't change any shared object. | You import arrayAt(list, -1) from a package and call it yourself. |
| Feature detection | It checks whether this browser has a feature before the code uses it. | It tests typeof Array.prototype.at === "function", or uses new Function to test whether new syntax can be read. |
This lesson builds on three earlier lessons: JavaScript history, engines & runtimes, and manuals & specs. In the tooling path, it comes after bundlers and before types for JavaScript.
You have a recipe full of new cooking words. An older cook doesn't know those words. So before cooking starts, you rewrite the recipe in words the cook knows. The dish comes out the same.
- In real life: A recipe with new cooking words
- In JavaScript: Your modern code
- In real life: The same recipe in words an older cook knows
- In JavaScript: Older JavaScript
- In real life: You rewrite it before the cook starts
- In JavaScript: The transpiler rewrites code before the browser reads it
In another country, your charger's plug doesn't fit the wall socket. You don't rebuild the charger. You add a small adapter, and then it fits. The charger stays the same; the adapter adds the missing piece.
- In real life: Your charger's plug
- In JavaScript: Code that calls a feature, like
list.at(-1) - In real life: A wall socket with a different shape
- In JavaScript: A browser that doesn't have that feature
- In real life: A travel adapter between them
- In JavaScript: A polyfill that adds the missing feature
A transpiler reads, changes, and rewrites code
STEP THROUGHFirst, here is the kind of code a transpiler fixes. This code prints Asha. ?. is called optional chaining. It reads name only if user.profile is not null or undefined.
An old browser can't read the ?. sign at all. It stops with a syntax error before any of the code runs.
const user = { profile: { name: "Asha" } };
console.log(user.profile?.name);The next block does the same job in older code. It is a simpler version of what a transpiler writes:
- Line 1 uses
var, the older way to make a variable. - Line 2 reads
user.profileonce and saves it inprofile. - Line 3 checks that
profileis notnulland notundefined.void 0is an old way to writeundefined. Only then does it readprofile.name.
It also prints Asha. Saving the profile first matters when reading it runs code. For example, a getter is a property that runs a function each time you read it. It should run only once here, just like in the new code.
var user = { profile: { name: "Asha" } };
var profile = user.profile;
console.log(profile === null || profile === void 0 ? void 0 : profile.name);Now the profile is missing. user.profile?.name gives undefined. Then ?? takes over. ?? is called nullish coalescing. It gives the value on its right when the left side is null or undefined. So this prints Guest.
Both ?. and ?? came in ES2020, the 2020 version of JavaScript. A browser older than that needs both of them rewritten.
const user = { profile: null };
console.log(user.profile?.name ?? "Guest");A real transpiler doesn't just search and replace text. It works in three steps:
- Parse: read the code and build a tree of its parts. This tree is called an AST (abstract syntax tree).
- Transform: change the parts of the tree that need it.
- Generate: print new code from the changed tree.
Because it works on the tree, the transpiler knows that ?? inside a string is just text, not code. Plain search and replace can't tell the difference.
?? part of the tree (transform) and prints older code from it (generate).The step-through below uses acorn, a real JavaScript parser, to build the tree. Then it rewrites one small ?? expression. It is much smaller than a real tool like Babel, so you can see each step.
Step through a real read, change, and write for one small ?? rewrite. The new line is built from the tree's positions, not typed by hand.
script
const label = settings.label ?? "Untitled";console.log(label);Made-up compiler output could teach you wrong things. So the output in the target playground below was made by the real TypeScript compiler. The lesson's tests run ts.transpileModule for ES5, ES2015, and ES2019 and check that the shown output still matches.
A target tells the tool how much to rewrite
PLAYGROUNDA target is the oldest JavaScript version your output must work in. The tool rewrites everything that is newer than the target.
With an ES5 target (JavaScript from 2009), all of these need rewriting:
- arrow functions and classes
- spread (
...items) and template literals (strings in backticks) - default values for parameters
?.and??
With an ES2019 target, most of those can stay as they are. But ?. and ?? still need rewriting, because they came in ES2020. Pick a target below and compare the output.
const tax = 0.08;const format = (name, amount = 0) => `${name}: $${amount.toFixed(2)}`;class Cart { constructor(items = []) { this.items = [...items]; } total() { return this.items?.reduce((sum, item) => sum + (item.price ?? 0), 0) ?? 0; }}const cart = new Cart([{ price: 12 }, {}]);console.log(format("Total", cart.total() * (1 + tax)));var __spreadArray = (this && this.__spreadArray) || function (to, from, pack) { if (pack || arguments.length === 2) for (var i = 0, l = from.length, ar; i < l; i++) { if (ar || !(i in from)) { if (!ar) ar = Array.prototype.slice.call(from, 0, i); ar[i] = from[i]; } } return to.concat(ar || Array.prototype.slice.call(from));};var tax = 0.08;var format = function (name, amount) { if (amount === void 0) { amount = 0; } return "".concat(name, ": $").concat(amount.toFixed(2));};var Cart = /** @class */ (function () { function Cart(items) { if (items === void 0) { items = []; } this.items = __spreadArray([], items, true); } Cart.prototype.total = function () { var _a, _b; return (_b = (_a = this.items) === null || _a === void 0 ? void 0 : _a.reduce(function (sum, item) { var _a; return sum + ((_a = item.price) !== null && _a !== void 0 ? _a : 0); }, 0)) !== null && _b !== void 0 ? _b : 0; }; return Cart;}());var cart = new Cart([{ price: 12 }, {}]);console.log(format("Total", cart.total() * (1 + tax)));ES5 browsers can't read arrow functions, classes, spread, template literals, default values, ?., or ??. So TypeScript rewrites all of them, and the output gets much longer.
ts.transpileModule in the lesson's tests. The page shows the saved results, so your browser doesn't have to download TypeScript.In a real app, you usually don't write a year as the target. Instead, many tools read Browserslist. Browserslist is a shared list of the browsers you support. Babel, Autoprefixer, eslint-plugin-compat, and other tools all read it.
You write a short query, and Browserslist turns it into a list of browser versions. For example:
defaults: a starting list that the Browserslist team keeps up to date.> 0.5%: browsers used by more than 0.5% of people.not dead: leave out browsers that no longer get updates.
A comma between queries adds more browsers. and keeps only the browsers that match both sides.
{
"browserslist": ["defaults"]
}This is a setting, not a program, so there is nothing to run. Before you change defaults, look at the browsers it gives you and compare them with your users.
MDN Baseline labels each web feature by how well the main browsers support it. The web-features project defines two labels:
- Newly available: the feature works in the latest version of every main browser.
- Widely available: it has worked in all of them for at least 30 months.
Use Baseline and caniuse as evidence. Then check which browsers your own users have.
Babel, SWC, TypeScript, and esbuild
TOOLSYou will meet four main transpilers:
- Babel is the oldest. It has the most plugins, which are add-ons that add more rewrites. Its
@babel/preset-envsetting reads your targets and picks the rewrites for you. It can also add polyfills. - SWC is written in the Rust language, so it is very fast. Next.js uses it, and this site is built with Next.js.
- TypeScript removes type notes from your code. It can also rewrite syntax for the
targetyou choose. - esbuild is a very fast bundler that can also rewrite syntax for a target.
Read each row of the table as: "this tool is good at this, and it fits these projects."
| Tool | What it is good at | Where it fits |
|---|---|---|
| Babel | It has many plugins, and @babel/preset-env picks rewrites from your targets. | Apps that need special rewrites, JSX plugins, or automatic core-js polyfills. |
| SWC | It is a fast compiler written in Rust. Next.js uses it. | Next.js projects. It removes TypeScript types, turns JSX into JavaScript, makes files smaller, and rewrites common syntax. |
| TypeScript compiler | It removes types and can rewrite syntax for a target. | Libraries and tests that need the same output every time, without Babel. |
| esbuild | It is a very fast bundler and transpiler written in Go. | Development builds and simple rewrites for a target. It has fewer compatibility plugins than Babel. |
Fast tools don't all do the same job. This site's code editor uses another fast tool called Sucrase. In the playground below, Sucrase does two things:
- It removes TypeScript types.
- It turns JSX into plain function calls. JSX is HTML-like tags written inside JavaScript, as in React.
In this playground, Sucrase is set to leave new JavaScript syntax like ?. and ?? as it is. So Sucrase alone can't make code work in old browsers. For that you still need Babel, SWC, TypeScript, or esbuild.
type User = { name?: string | null };
const user: User = { name: null };
const label = user.name ?? "Guest";
const view = <strong>{label?.toUpperCase()}</strong>;
console.log(label, view.type);const _jsxFileName = "";
const user = { name: null };
const label = user.name ?? "Guest";
const view = React.createElement('strong', {__self: this, __source: {fileName: _jsxFileName, lineNumber: 4}}, label?.toUpperCase());
console.log(label, view.type);Sucrase removes the TypeScript types and turns JSX into React.createElement calls. With disableESTransforms on, it leaves ?. and ?? as they are.
// babel.config.js
module.exports = {
presets: [
["@babel/preset-env", {
targets: "> 0.5%, last 2 versions, not dead",
useBuiltIns: "usage",
corejs: "3.38"
}]
]
};This is a build setting, not code for the browser. Line by line:
- Line 5,
targets, lists the browsers as a Browserslist query. - Line 6,
useBuiltIns: "usage", adds polyfills only for features your code uses. - Line 7,
corejs, says which version of thecore-jspolyfill package you installed. Make it match your installed version;3.38is just an example.
In a Next.js app, let Next.js and SWC do the rewriting. Add your own Babel setup only if you need a plugin that only Babel has. Switching to Babel can make builds slower and change the output.
A polyfill adds a missing feature while the page runs
STEP THROUGHSometimes the browser can read your code, but a built-in function is missing. A polyfill helps here. A safe polyfill does three things:
- It checks whether the browser already has the feature.
- It adds the feature only when it is missing.
- It copies the real feature's details as closely as it can. For example, the new method should not show up when code lists an object's properties.
This small teaching version adds at to a small object called methods. It doesn't touch the real Array.prototype. at(-1) means "the last item".
const methods = {};
if (!("at" in methods)) {
Object.defineProperty(methods, "at", {
value(index) { return this[index < 0 ? this.length + index : index]; }
});
}
console.log(methods.at.call(["tea", "milk"], -1));- Line 2 checks for
at. If it is already there, the code skips adding it. - Line 3 adds
atwithObject.defineProperty. This function adds a property and lets you set its details. - Line 4 turns a negative index into a position counted from the end.
- Line 7 runs
aton the list["tea", "milk"]. It uses.call(), which runs a function with a chosenthis. It printsmilk.
This tiny method skips some unusual cases from the specification. So don't use it as a real Array.prototype.at polyfill. The step-through below adds range checks. It also keeps the new method hidden from lists of properties; this is called non-enumerable. For a real app, use a polyfill that someone keeps up to date.
A safe polyfill checks for the feature, adds it only when it's missing, and keeps the new method hidden from property lists.
script
function installAtPolyfill(proto) { if ("at" in proto) return "native kept"; Object.defineProperty(proto, "at", { value(index) { const length = this.length >>> 0; const relative = Math.trunc(index) || 0; const key = relative < 0 ? length + relative : relative; return key < 0 || key >= length ? undefined : this[key]; }, configurable: true, writable: true }); return "polyfill installed";} const list = Object.assign(Object.create(fakePrototype), { 0: "alpha", 1: "omega", length: 2});console.log(installAtPolyfill(fakePrototype));console.log(list.at(-1));console.log(Object.keys(fakePrototype).includes("at"));When at already exists, the check on line 2 leaves it alone. So this code prints native. Replacing a built-in method that already works can break other code. The browser's own version is also usually faster.
const methods = { at() { return "native"; } };
if (!("at" in methods)) {
Object.defineProperty(methods, "at", { value() { return "replacement"; } });
}
console.log(methods.at());at first. If it is missing, the polyfill adds it. If it is already there, the polyfill leaves it alone.Sometimes only your own code needs the last item. Then a ponyfill is enough. A ponyfill is a normal helper function. It doesn't change any shared object.
This code prints milk. But unlike a polyfill, it doesn't make cart.at(-1) work for other code.
const cart = ["tea", "milk"];
const last = (items) => items[items.length - 1];
console.log(last(cart));Real polyfills follow the specification closely and are kept up to date. core-js is the most widely used package of JavaScript polyfills.
Babel's @babel/preset-env can add core-js imports for you when useBuiltIns: "usage" is on. It reads your code and imports only the polyfills your target browsers seem to need. It can miss some uses:
- a method called through a name made while the code runs, like
list[name]() - code inside a package that Babel doesn't process
Every polyfill costs time: the browser must download it, read it, and run it. So check what your final bundle contains. Remove polyfills for features your supported browsers already have. Keep the ones that the older browsers you support still need.
polyfill.io was a service that sent polyfills to web pages. In February 2024, its domain and GitHub account got a new owner. In June 2024, security researchers and Cloudflare reported that scripts from cdn.polyfill.io sometimes contained harmful code. For example, some phone users were sent to other websites.
Cloudflare and Fastly offered safe copies, and researchers said to remove every polyfill.io link. The lesson: a script from someone else's server is part of your app. This is called a supply-chain risk. So pin the exact version, check who owns it, and host it on your own server when you can.
Check for the feature, not the browser name
REAL OUTPUTEvery browser sends a user-agent string: a line of text with its name and version. Guessing features from it doesn't work well:
- Browsers copy parts of each other's names.
- Company versions of a browser can be older than their version number suggests.
- WebViews inside apps get updates at different times.
- A feature can be turned off.
Feature detection asks the browser directly. Is this method there? Can this syntax be read? Does CSS support this property and value?
On a profile page, you can check for at and use a fallback if it is missing. Nothing shared gets changed:
- Line 2 checks whether
user.names.atis a function. - If it is, line 3 uses
at(-1). - If not, line 4 uses
length - 1, the position of the last item.
Both ways pick the last name, so this prints Nia.
const user = { names: ["Asha", "Nia"] };
const lastName = typeof user.names.at === "function"
? user.names.at(-1)
: user.names[user.names.length - 1];
console.log(lastName);at is a function. Either way, the code picks the last name, Nia.The next code checks two things. In a current browser, both lines print true.
function supportsOptionalChainingSyntax() { try { new Function("const item = {}; return item?.name ?? 'none';"); return true; } catch { return false; }} console.log(typeof Array.prototype.at === "function");console.log(supportsOptionalChainingSyntax());- Line 10 uses
typeofto check thatArray.prototype.atis a function.typeofgives a value's type as a string, like"function". - Line 3 uses
new Functionto make a function from a fixed piece of text. If the browser can't read?.or??, this throws an error. Then thecatchon line 5 catches it, and line 6 returnsfalse.
A few more things to know:
"name" in objectonly checks that a property exists.typeofalso tells you whether it can be called.- A strict Content Security Policy is a page rule that limits what scripts may do. It can block
new Function. Then the check returnsfalseeven in a browser that knows the syntax. So for syntax your app needs, set build targets instead. - For CSS,
@supportsandCSS.supports("container-type", "inline-size")ask the same kind of question.
Syntax checks use fixed text that you wrote. Never pass user text, search words, or server data to eval or new Function. That would let anyone run their own code on your page.
How real projects put it together
A real build starts with a support policy: a decision about which browsers you support. To decide, ask:
- Which browsers do your users have?
- How much extra old-style code can you send them?
- Do your users open your pages inside apps, in older WebViews?
Then the tools turn that policy into rewrites, polyfills, and fallbacks:
- Write modern code and tests.
- Choose your target browsers with Browserslist. Use Baseline, your analytics, and what you promised customers.
- Let Babel, SWC, TypeScript, or esbuild rewrite the syntax for those browsers.
- Add checked polyfills or ponyfills for features those browsers lack.
- Check for risky features in your code, and keep a fallback for features no tool can add.
A transpiler keeps your code's meaning, mistakes included. Say a timeout of zero means "no timeout". Then use ??, not ||:
- Line 2 prints
0.??only replacesnullandundefined, so it keeps the zero. - Line 3 prints
1000.||replaces any falsy value, which is a value that counts as false, like0or"". So the zero is lost.
Transpiled code keeps this same difference.
const settings = { timeout: 0 };
console.log(settings.timeout ?? 1000);
console.log(settings.timeout || 1000);Older apps often used differential serving: sending different files to different browsers.
- Browsers that understand
type="module"load the modern file and skip the other one. - Older browsers skip the module tag and load the
nomodulefile instead.
Today most people use browsers that update themselves, so this is less common. You will still see it in older apps.
<script type="module" src="/app-modern.js"></script>
<script nomodule src="/app-legacy.js"></script>These tags belong in an HTML page next to built script files, so there is nothing to run here. Always check the files your build really sends, not just your settings.
What can't be transpiled or polyfilled?
These tools help a lot, but they can't do everything:
- A syntax error can only be avoided by rewriting the code before the browser reads it.
- A missing function can often be added.
- Some features depend on how the engine works inside. Examples are security rules, garbage collection (freeing memory that nothing uses), and device hardware. No code can add those.
Read each row of the table as: "for this kind of problem, use this fix, for example here."
| Kind of problem | What usually fixes it | Examples |
|---|---|---|
| Syntax: new ways to write code. | Transpile it before the browser reads it. | Optional chaining, class fields, private methods, JSX, and TypeScript types. |
| APIs: built-in functions and objects. | Add a polyfill or ponyfill while the page runs. | Array.prototype.at, Promise, Object.groupBy, and fetch in older browsers. |
| Semantics: how the engine itself behaves. | Often no tool can fully fix it. | Every Proxy feature, when WeakRef lets go of an object, exact module loading, and some speed details. |
| Platform behavior: what the device and browser allow. | Check for the feature and plan a fallback. | CSS support, storage limits, audio and video formats, input devices, and permission prompts. |
user.profile?.name ?? "Guest"must run in browsers that can't read?..list.at(-1)is missing, but the browser can read the call.- A library needs every
Proxyfeature in Internet Explorer 11. Object.groupBy(items, fn)is missing in an otherwise modern browser.- Template literals (backtick strings) must work in a WebView that only knows ES5.
- Code depends on exactly when
WeakReflets go of an object.
Put each problem under the fix that can solve it.
Common misconceptions
- “Babel makes everything work everywhere.” Babel rewrites syntax. Missing functions still need polyfills. And some engine features can't be added at all.
- “A polyfill can fix syntax.” If the browser can't read a sign like
?., it stops before any code runs. So the polyfill never runs either. - “Baseline means I can ignore my users.” Baseline tells you about the main browsers. Your own users, and what you promised them, still matter.
- “Automatic polyfills don't need checking.” Polyfills added for you are still code from someone else. Check their size, where they come from, who owns them, and what ends up in your files.
- “Checking the browser name is good enough.” A feature check still works when a browser copies another's name, gets patched, or runs inside an app. Names and version numbers don't.
Read each row as: "when someone says this, ask this question, then do this instead."
| If you hear | Ask | Do this instead |
|---|---|---|
| Just transpile it. | Is the problem new syntax, or a missing function? | Match each feature to the fix that can change it. |
| Use a polyfill website. | Who owns the script, and can it send different code each time? | Use trusted packages and exact versions, and host them yourself when you can. |
| Target modern browsers. | Which browsers, and how old? | Write a Browserslist query. Check it with Baseline, caniuse, and your analytics. |
Practice exercises
5 EXERCISESPredict what this code prints in Node 22 or a current Chrome.
const supportsAt = typeof Array.prototype.at === "function";
console.log(supportsAt ? ["first", "last"].at(-1) : "fallback");supportsAt is true, so the code calls at(-1) and prints last.
Type the two bugs in this code.
const prototype = { at() { return "native"; } };
prototype.at = function(index) {
return this[index];
};
const list = Object.assign(Object.create(prototype), {
0: "first",
1: "last",
length: 2
});
console.log(list.at(-1));Bug 1: line 2 replaces a method that already works. Bug 2: it reads this[index] directly, so -1 gives undefined instead of the last item. A safe polyfill checks first and handles negative numbers.
Read the helper and type the word it prints.
function at(list, index) {
const length = list.length >>> 0;
const relative = Math.trunc(index) || 0;
const key = relative < 0 ? length + relative : relative;
return key < 0 || key >= length ? undefined : list[key];
}
console.log(at(["first", "last"], -1));length is 2 and relative is -1, so key is 1. The helper returns last and changes nothing shared.
Type what this simple target check prints.
const source = "const value = options.timeout ?? 1000";
const target = "ES2019";
console.log(target === "ES2020" ? "keep ??" : "rewrite ??");The target is ES2019, which is older than ??. So the code prints rewrite ??.
Should a polyfill check for the feature itself (feature detection), or check the browser's name (user-agent sniffing)?
if (!("at" in Array.prototype)) { /* define it */ }Feature detection asks the browser directly whether the method exists. If it doesn't, the polyfill adds it with Object.defineProperty, hidden from property lists, and never replaces a built-in method.
Check your understanding
7 QUESTIONSQuestion 1 of 7Which sentence best describes transpiling?
Choose an answer to see the explanation.
Question 2 of 7What does this feature check print first in Node 22 and current Chrome?
Read the code, then predictconst supportsAt = typeof Array.prototype.at === "function"; console.log(supportsAt); console.log(["a", "b"].at(-1));Choose an answer to see the explanation.
Question 3 of 7Which Browserslist query is a sensible everyday choice that leaves out dead browsers?
Choose an answer to see the explanation.
Question 4 of 7What should a safe polyfill do before it adds a built-in method?
Choose an answer to see the explanation.
Question 5 of 7Which problem belongs under 'neither'?
Choose an answer to see the explanation.
Question 6 of 7What does this target check print?
Read the code, then predictconst source = "const value = options.timeout ?? 1000"; const target = "ES2019"; console.log(target === "ES2020" ? "keep ??" : "rewrite ??");Choose an answer to see the explanation.
Question 7 of 7What did the 2024 Polyfill.io problem teach?
Choose an answer to see the explanation.
Key takeaways
- A transpiler rewrites code in three steps: read it into a tree (parse), change the tree (transform), and print new code (generate).
- New syntax must be transpiled before an old browser reads it. Missing functions need a polyfill or a ponyfill while the page runs.
- Browserslist says which browsers you support. Baseline and caniuse tell you what those browsers can do.
- Babel, SWC, TypeScript, and esbuild do similar jobs, but their plugins and output differ.
- A safe polyfill checks first, never replaces a built-in method, stays hidden from property lists, and comes from a source you trust.
- Some features, like every part of
Proxyor exactly whenWeakReflets go of an object, can't be added to old engines.
Remember the one-liner.
Transpile new syntax, polyfill missing functions, check for features in code, and stop supporting browsers that can't run a feature safely.
Up next: Types for JavaScript. There, JSDoc and TypeScript catch mistakes before your build tools ever touch the code.