W3docs

JavaScript Polyfills and Transpilers

Learn how modern JavaScript runs on older engines using transpilers like Babel to convert new syntax and polyfills like core-js to add missing built-in methods.

JavaScript gains new features every year. Each yearly release — ES2015 (ES6), ES2020, ES2022, and so on — adds fresh syntax and new built-in methods. The problem is that your code does not run in one fixed engine: it runs in whatever browser or runtime your visitors happen to use, and some of those are years out of date. Code that works perfectly in the latest Chrome may throw a SyntaxError in an older one, or quietly fail because a method like Array.prototype.includes simply does not exist.

There are two separate gaps to close, and they call for two different tools. New syntax that an old engine cannot even parse needs a transpiler. New built-in APIs that an old engine never shipped need a polyfill. This chapter explains both, shows how they differ, and describes how they fit into a modern build toolchain.

The Two Gaps: Syntax vs. APIs

Before reaching for a tool, it helps to see why one tool is not enough.

  • Syntax is the grammar of the language — arrow functions, class, optional chaining (?.), the nullish coalescing operator (??), template literals. If an engine does not understand the grammar, the script fails to parse and nothing runs. You cannot fix this at runtime, because the file is rejected before any code executes.
  • APIs are the built-in functions and objects available while your code runs — Promise, Array.prototype.includes, String.prototype.padStart, Object.fromEntries, fetch. These are just values living on global objects and prototypes. If one is missing, you can add it yourself before your code uses it.

That split is the whole story: rewrite the grammar ahead of time, or supply the missing values at runtime.

Transpilers: New Syntax to Old Syntax

A transpiler (also called a source-to-source compiler) reads your modern JavaScript and rewrites it into equivalent older JavaScript that more engines understand. The best-known transpiler is Babel. This happens at build time — before your code ever ships — so the browser only ever receives syntax it can parse.

Here is a tiny before-and-after. You write a modern arrow function:

// Source — modern syntax
const double = x => x * 2;

A transpiler targeting older engines rewrites it into a classic function expression:

// Output — down-leveled to ES5
var double = function (x) {
  return x * 2;
};

The behavior is identical; only the grammar changed. Babel does the same for class declarations, destructuring, default parameters, optional chaining, and more. For example, user?.address?.city becomes a series of && checks that older engines handle without trouble.

@babel/preset-env and browserslist

You rarely configure each feature by hand. Instead, you use @babel/preset-env, a preset that decides which transformations to apply based on the environments you want to support. Those environments are declared with a browserslist query — a short, shared way to describe your target browsers:

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

With this list, @babel/preset-env transpiles only what those browsers actually lack. Tighten the list to modern browsers and almost nothing gets down-leveled; widen it to ancient ones and far more transformation happens. The key idea: a transpiler is a build step, and browserslist tells it how hard to work.

Polyfills: Supplying Missing APIs at Runtime

A polyfill is a piece of code that adds a missing built-in so an old engine gains the API at runtime. A transpiler can rewrite ?. syntax, but it cannot conjure up a Promise object that the engine never shipped — that is a job for a polyfill. The most widely used polyfill library is core-js, which provides implementations for Promise, Array.from, Object.fromEntries, String.prototype.padStart, and hundreds more.

You can also write a small polyfill by hand. The essential pattern is a feature-detection guard — an if check that only installs your version when the native one is absent:

if (!String.prototype.padStart) {
  String.prototype.padStart = function (targetLength, padString) {
    targetLength = Math.floor(targetLength) || 0;
    if (targetLength < this.length) {
      return String(this);
    }

    padString = padString ? String(padString) : ' ';

    let pad = '';
    const len = targetLength - this.length;
    let i = 0;
    while (pad.length < len) {
      if (!padString[i]) {
        i = 0;
      }
      pad += padString[i];
      i++;
    }

    return pad + String(this).slice(0);
  };
}

The if (!String.prototype.padStart) line is the important part. Without it, you would overwrite the engine's native implementation every time — replacing fast, well-tested built-in code with your own. The guard says "only step in when the feature is genuinely missing," so modern engines keep their optimized version and only old engines fall back to yours.

The example below detects and uses padStart the same way a polyfill would. In a modern browser the native method runs; in an ancient one, the guarded fallback above would have supplied it first.

javascript— editable
Warning

Modifying built-in prototypes (like String.prototype) is something only polyfills should do, and only behind a feature-detection guard. In your own application code, avoid adding methods to native prototypes — it can clash with other libraries and future language features.

Transpiler vs. Polyfill at a Glance

The two tools are easy to confuse because both exist to support older engines. This contrast keeps them straight:

AspectTranspiler (e.g. Babel)Polyfill (e.g. core-js)
FixesNew syntax the engine cannot parseMissing built-in APIs
When it runsBuild time (before shipping)Runtime (in the browser)
Example input?., ??, arrow functions, classPromise, fetch, Array.prototype.includes
How it worksRewrites code into older grammarAdds the missing function/object
Can it handle the other gap?No — cannot add missing APIsNo — cannot fix unparseable syntax

A simple rule of thumb: if an engine cannot read your code, you need a transpiler; if it can read it but a function is undefined, you need a polyfill. Most real projects use both at once.

How This Fits a Modern Toolchain

In practice you do not run these tools by hand. A bundler or build tool — such as Vite, webpack, or esbuild — drives them for you. A typical setup works like this:

  1. You declare your target environments once, in browserslist.
  2. The build tool runs Babel with @babel/preset-env, which down-levels only the syntax your targets lack.
  3. The same configuration injects core-js polyfills for the APIs those targets are missing — and, with useBuiltIns: 'usage', only the ones your code actually references.

The result is a bundle tuned to your real audience: nothing is transformed or polyfilled that your visitors' browsers already support.

Info

Today's evergreen browsers — Chrome, Edge, Firefox, and Safari — auto-update and already support the vast majority of modern ES6 and later features. Heavy transpiling and broad polyfilling are far less necessary than they once were. Set a realistic browserslist target for your audience and let the toolchain down-level and polyfill only what is genuinely needed.

There is a real cost to ignoring this advice. Over-polyfilling bloats your bundle with code that every modern visitor downloads, parses, and throws away unused. Down-leveling too aggressively also produces larger, slower output. The goal is not "support everything" but "support what your users actually run." To reason about which features a given browser supports, the chapter on DOM and browser compatibility is a useful companion.

Test Your Knowledge

Practice
Which tool rewrites modern JavaScript syntax into an equivalent older syntax at build time?
Which tool rewrites modern JavaScript syntax into an equivalent older syntax at build time?
Practice
Why does a hand-written polyfill begin with a check like 'if (!String.prototype.padStart)'?
Why does a hand-written polyfill begin with a check like 'if (!String.prototype.padStart)'?
Practice
Which gap can a polyfill close, but a transpiler cannot?
Which gap can a polyfill close, but a transpiler cannot?
Was this page helpful?