completefrontend
Open lab My progress
THE JAVASCRIPT FIELD GUIDE

Transpilers & polyfills

Learn how Babel, SWC, Browserslist, Baseline, feature detection, and core-js let modern JavaScript run safely in older browsers.

By the end, you can
  • 01
    Name the gapExplain why the code you write may not run in your users' browsers.
  • 02
    Choose the right fixDecide whether a problem needs a transpiler, a polyfill, a ponyfill, a feature check, or a fallback.
  • 03
    Use 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.

The main idea

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.

Left: a transpiler rewrites the new ?. syntax into an older === null check before the code runs. Right: a polyfill adds a missing at() method to the methods object while the code runs.transpiler: before it runs?.=== nullnew syntaxold syntaxpolyfill: while it runsat()methodsat() added if missing
A transpiler changes your code before the browser reads it. A polyfill adds a missing method while the page runs.

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."

Four tools that help old browsers run new code
ToolWhat it changesAn example
TranspilerIt rewrites your code into older syntax before users download it.It turns items?.[0] ?? fallback into older checks that an old browser can read.
PolyfillIt 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.
PonyfillIt 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 detectionIt 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.

Real-life analogyTranspiling is like rewriting a recipe

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
Real-life analogyA polyfill is like a travel adapter

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 THROUGH

First, 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.

New code: read a name with ?.Pop out in the code editor (opens in a new tab)JavaScript
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.profile once and saves it in profile.
  • Line 3 checks that profile is not null and not undefined. void 0 is an old way to write undefined. Only then does it read profile.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.

Old code: check for null firstPop out in the code editor (opens in a new tab)JavaScript
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.

No profile, so use GuestPop out in the code editor (opens in a new tab)JavaScript
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:

  1. Parse: read the code and build a tree of its parts. This tree is called an AST (abstract syntax tree).
  2. Transform: change the parts of the tree that need it.
  3. 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.

Three steps. 1. Parse: the new code with ?? is read into a tree called the AST. 2. Transform: the node with operator ?? is changed. 3. Generate: older code with !== null is printed.??new code1. parseoperator: "??"AST2. transform3. generate!== nullolder code
First the transpiler reads the code into a tree (parse). Then it changes the ?? 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.

A tiny tree rewrite for ??
Step 0 of 4Ready
Your turn: follow the blue line

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.

Running in
  1. script
Next: line 1
const label = settings.label ?? "Untitled";console.log(label);
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.
The compiler output here is real

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

PLAYGROUND

A 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.

Pick a target and see real compiler output
Modern code given to the compilerPop out in the code editor (opens in a new tab)JavaScript
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)));
Output for ES5real compiler output
TypeScript output for ES5Pop out in the code editor (opens in a new tab)JavaScript
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)));
Try it yourself
TypeScript compiler target

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.

The TypeScript compiler made these outputs with 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.

Choose your browsers in package.jsonJSON
{
  "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.

Baseline tells you about browsers, not about 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

TOOLS

You 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-env setting 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 target you 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."

Transpilers you will meet in real projects
ToolWhat it is good atWhere it fits
BabelIt has many plugins, and @babel/preset-env picks rewrites from your targets.Apps that need special rewrites, JSX plugins, or automatic core-js polyfills.
SWCIt 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 compilerIt removes types and can rewrite syntax for a target.Libraries and tests that need the same output every time, without Babel.
esbuildIt 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.

Remove TypeScript and JSX with Sucrase
Input you can edit (TSX)TSX
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);
Sucrase outputtypes removed
Sucrase outputJavaScript
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);
Try it yourself

Sucrase removes the TypeScript types and turns JSX into React.createElement calls. With disableESTransforms on, it leaves ?. and ?? as they are.

Fast tools used while you develop often work like this. They remove extra layers like types, but they don't make old browsers understand new JavaScript.
A Babel setting that adds only the polyfills you useJavaScript
// 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 the core-js polyfill package you installed. Make it match your installed version; 3.38 is 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 THROUGH

Sometimes 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".

Add at only if it is missingPop out in the code editor (opens in a new tab)JavaScript
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 at with Object.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 at on the list ["tea", "milk"]. It uses .call(), which runs a function with a chosen this. It prints milk.

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.

Add a safe at method, step by step
Step 0 of 6Ready
Your turn: follow the blue line

A safe polyfill checks for the feature, adds it only when it's missing, and keeps the new method hidden from property lists.

Running in
  1. script
Next: line 16
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"));
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

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.

Keep the method that is already therePop out in the code editor (opens in a new tab)JavaScript
const methods = { at() { return "native"; } };
if (!("at" in methods)) {
  Object.defineProperty(methods, "at", { value() { return "replacement"; } });
}
console.log(methods.at());
Left: the methods object has no at method, so the polyfill adds at() and the code prints milk. Right: methods already has its own at() method, so the polyfill leaves it alone and the code prints native.at() missing: add itat()methodsprints milkat() already there: keep itat()methodsprints native
Line 2 checks for 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.

A helper function instead of a polyfillPop out in the code editor (opens in a new tab)JavaScript
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.

A warning from 2024: Polyfill.io

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 OUTPUT

Every 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.at is 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.

Use at, or a fallback if it is missingPop out in the code editor (opens in a new tab)JavaScript
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);
The code asks: is typeof user.names.at equal to function? If yes, it uses at(-1). If no, it uses length - 1. Both ways give Nia.typeof user.names.at === "function"yesnoat(-1)length - 1Nia
Line 2 checks whether 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.

Check for a method and for new syntaxPop out in the code editor (opens in a new tab)JavaScript
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 typeof to check that Array.prototype.at is a function. typeof gives a value's type as a string, like "function".
  • Line 3 uses new Function to make a function from a fixed piece of text. If the browser can't read ?. or ??, this throws an error. Then the catch on line 5 catches it, and line 6 returns false.

A few more things to know:

  • "name" in object only checks that a property exists. typeof also 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 returns false even in a browser that knows the syntax. So for syntax your app needs, set build targets instead.
  • For CSS, @supports and CSS.supports("container-type", "inline-size") ask the same kind of question.
Never turn user text into code

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:

  1. Write modern code and tests.
  2. Choose your target browsers with Browserslist. Use Baseline, your analytics, and what you promised customers.
  3. Let Babel, SWC, TypeScript, or esbuild rewrite the syntax for those browsers.
  4. Add checked polyfills or ponyfills for features those browsers lack.
  5. 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 replaces null and undefined, so it keeps the zero.
  • Line 3 prints 1000. || replaces any falsy value, which is a value that counts as false, like 0 or "". So the zero is lost.

Transpiled code keeps this same difference.

?? keeps a zero, || does notPop out in the code editor (opens in a new tab)JavaScript
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 nomodule file instead.

Today most people use browsers that update themselves, so this is less common. You will still see it in older apps.

Old pattern: a modern file and an old fileHTML
<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."

Which fix works for which problem
Kind of problemWhat usually fixes itExamples
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.
Transpile, polyfill, or neither?
  • 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 Proxy feature 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 WeakRef lets go of an object.
Try it yourself
0 of 6 correct

Put each problem under the fix that can solve it.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

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."

Questions to ask about common advice
If you hearAskDo 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 EXERCISES
Exercise 1 · Warm-upCheck for a method that exists

Predict what this code prints in Node 22 or a current Chrome.

Starter codePop out in the code editor (opens in a new tab)JavaScript
const supportsAt = typeof Array.prototype.at === "function";
console.log(supportsAt ? ["first", "last"].at(-1) : "fallback");

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · PracticeFind the two bugs in this polyfill

    Type the two bugs in this code.

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    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));

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeUse a helper instead of changing shared objects

      Read the helper and type the word it prints.

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      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));

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeDoes ?? need rewriting?

        Type what this simple target check prints.

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        const source = "const value = options.timeout ?? 1000";
        const target = "ES2019";
        console.log(target === "ES2020" ? "keep ??" : "rewrite ??");

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengePick the safe way to install

          Should a polyfill check for the feature itself (feature detection), or check the browser's name (user-agent sniffing)?

          Answer, then press Check. Spacing and letter case don’t matter.

            Check your understanding

            7 QUESTIONS
            Lesson quiz · 7 questionsScore: first tries count
            1. Question 1 of 7Which sentence best describes transpiling?

              Choose an answer to see the explanation.

            2. Question 2 of 7What does this feature check print first in Node 22 and current Chrome?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const supportsAt = typeof Array.prototype.at === "function";
              console.log(supportsAt);
              console.log(["a", "b"].at(-1));

              Choose an answer to see the explanation.

            3. Question 3 of 7Which Browserslist query is a sensible everyday choice that leaves out dead browsers?

              Choose an answer to see the explanation.

            4. Question 4 of 7What should a safe polyfill do before it adds a built-in method?

              Choose an answer to see the explanation.

            5. Question 5 of 7Which problem belongs under 'neither'?

              Choose an answer to see the explanation.

            6. Question 6 of 7What does this target check print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const source = "const value = options.timeout ?? 1000";
              const target = "ES2019";
              console.log(target === "ES2020" ? "keep ??" : "rewrite ??");

              Choose an answer to see the explanation.

            7. 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 Proxy or exactly when WeakRef lets 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.

            CompleteFrontend Clear concepts. Working examples.