cf.completefrontendCode editorOpen lab
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 JavaScript you write may not match the syntax and APIs your users' browsers understand.
  • 02
    Choose the right fixDecide whether a change needs transpiling, a polyfill, a ponyfill, feature detection, or a fallback.
  • 03
    Read tool outputUse Browserslist, Baseline, Babel, SWC, TypeScript, and core-js without pretending they can solve every compatibility problem.

The compatibility gap

Modern JavaScript moves faster than the devices your users keep in their pockets, corporate laptops, embedded WebViews, and smart TVs. The code you write is one side of the gap. The syntax and APIs a user’s browser can parse and run are the other side.

Plain definition

A transpiler is a source-to-source compiler: it parses your code, builds an AST, transforms that tree, and generates different JavaScript. A polyfill is runtime code that adds a missing API when the browser can already parse the surrounding syntax.

The practical rule is short: syntax needs transpiling; APIs need polyfills or ponyfills; features with engine-level semantics may need a different design or a higher browser requirement.

Four compatibility tools that are easy to mix up
ToolWhat it changesExample
TranspilerRewrites source syntax before users download it.Turns items?.[0] ?? fallback into older JavaScript guards for targets that cannot parse it.
PolyfillAdds a missing runtime API, usually on a global or prototype.Defines Array.prototype.at when an older browser lacks it.
PonyfillExports a helper instead of changing globals.Imports arrayAt(list, -1) from a package and calls that helper directly.
Feature detectionChecks whether the current environment supports a feature before using it.Tests typeof Array.prototype.at === "function" or uses new Function for syntax probes.

This lesson connects the history from JavaScript history, the runtime model from engines & runtimes, and the standards mindset from manuals & specs. It also sits between bundlers and types for JavaScript in the tooling path.

Real-life analogyTranspiling is translating a recipe

Translate a new recipe into steps an older cook understands before cooking starts. A transpiler changes syntax before the browser reads it.

In real life: A new recipe
In JavaScript: Modern source syntax
In real life: Steps an older cook understands
In JavaScript: Older JavaScript syntax
In real life: Rewrite before cooking
In JavaScript: Transform source before the browser parses it

Where the analogy stops: A translated recipe can still need ingredients the old kitchen does not have. Syntax changes do not add APIs.

Real-life analogyA polyfill is a travel adapter

A travel adapter lets an old plug fit a new socket. A polyfill adds a missing API after the browser has already read the code.

In real life: An old plug
In JavaScript: Code that needs an API
In real life: An adapter
In JavaScript: A polyfill
In real life: A new socket
In JavaScript: A browser missing that API

Where the analogy stops: An adapter cannot help if the plug shape is unreadable. A polyfill runs only after parsing succeeds.

Transpiling is parse, transform, generate

STEP THROUGH

A real transpiler does not search and replace random text. It parses tokens into an abstract syntax tree, runs transforms over nodes, and prints new source. That is why tools can preserve strings, comments, and nested expressions more safely than a regular expression replacement.

The step-through below uses acorn to parse a real AST and rewrites one simple ?? expression from node positions. It is deliberately tiny, so you can see the pipeline without pretending it is Babel.

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

Step through a real parse-and-rewrite pipeline for one tiny ?? transform. The visible output is generated from the AST positions, not typed by hand in the player.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
const label = settings.label ?? "Untitled";console.log(label);
CallStoreChangeResultRun = next line. 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.
Why not hand-written fake output?

Fake compiler output teaches the wrong lesson. The playgrounds in this article display stored output generated by the TypeScript compiler in tests, and the tests compare those strings with ts.transpileModule for ES5, ES2015, and ES2019 targets.

Targets tell tools how far to rewrite

PLAYGROUND

Compilers need a target. If you target ES5, arrow functions, classes, spread, template literals, default parameters, optional chaining, and nullish coalescing all need attention. If you target ES2019, many of those are already parseable, but ?. and ?? still need rewriting.

Pick a target and inspect real compiler output
Modern source 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

TypeScript rewrites arrows, classes, spread, template literals, defaults, optional chaining, and nullish coalescing because ES5 browsers parse none of that syntax.

The stored outputs are generated by the TypeScript compiler in tests with ts.transpileModule, then reused in the browser so the client does not ship the TypeScript package.

The target is usually not a raw ECMAScript year in application projects. Build tools often read Browserslist, a shared query format used by Babel, Autoprefixer, eslint-plugin-compat, and other tools. Queries such as defaults or > 0.5%, last 2 versions, not dead turn product support policy into a set of browser versions.

Browserslist in package.jsonbash
# package.json
"browserslist": [
  "defaults",
  "> 0.5%",
  "last 2 versions",
  "not dead"
]
Baseline is a compatibility signal, not your analytics

MDN Baseline labels web platform features by support across core browsers. The web-features project defines the data model: Newly available means the feature works in the latest stable core browsers; Widely available means the keystone date is at least 30 months old. Use Baseline and caniuse as evidence, then compare with your users.

Babel, SWC, TypeScript, and esbuild

TOOLS

Babel is the long-running JavaScript transform platform. Its @babel/preset-env preset combines your targets with syntax transforms and, when configured, polyfill injection. SWC is written in Rust and powers Next.js transforms in this site. TypeScript strips types and can downlevel syntax by target. esbuild is a very fast bundler and transform tool with target-based syntax lowering.

Transpilers you will meet in production
ToolStrengthCommon fit
BabelPlugin ecosystem and @babel/preset-env.Applications that need specific transforms, JSX plugins, or usage-based core-js injection.
SWCRust compiler used by Next.js for fast transforms.Next.js projects, TypeScript stripping, JSX, minification, and common syntax downleveling.
TypeScript compilerStrips types and can downlevel syntax by target.Libraries or tests that need a deterministic compiler output without Babel in the browser.
esbuildVery fast Go-based transform and bundler.Development builds and simple target-based syntax transforms, with fewer compatibility plugins than Babel.

Fast tools are not identical. Sucrase, used by this site’s code editor, is excellent for quick TypeScript and JSX transforms. In this playground it strips TypeScript and lowers JSX but intentionally leaves modern JavaScript syntax alone, so it is not a replacement for a Babel, SWC, TypeScript, or esbuild compatibility pass.

Strip TypeScript and JSX with Sucrase
Editable TSX inputTSX
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 stripped
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 TypeScript types and lowers JSX to React.createElement, but with disableESTransforms it leaves optional chaining and nullish coalescing in place.

This mirrors how fast dev transforms often strip syntax layers without making old browsers understand every new JavaScript feature.
Babel preset-env with usage-based core-jsJavaScript
// babel.config.js
module.exports = {
  presets: [
    ["@babel/preset-env", {
      targets: "> 0.5%, last 2 versions, not dead",
      useBuiltIns: "usage",
      corejs: "3.38"
    }]
  ]
};

In a Next.js app, you usually let Next’s SWC pipeline handle framework transforms. You add custom Babel only when you truly need Babel-specific plugins, because switching compilers can change build performance and output.

Polyfills add missing APIs at runtime

STEP THROUGH

Once a browser can parse your code, it might still lack an API. That is where a polyfill helps. A safe polyfill checks for the native feature, defines only when missing, and tries to match platform details such as property attributes and edge cases.

Install a safe Array.prototype.at style polyfill
Step 0 of 6Ready
Your turn: follow the blue line

A safe polyfill checks for the feature, installs only when missing, and defines the method without making it enumerable.

Running in
  1. script
Next: line 16
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
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"));
CallStoreChangeResultRun = next line. 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.

Real production polyfills are maintained against the specification. core-js is the ecosystem workhorse for many JavaScript standard-library polyfills. Babel’s @babel/preset-env can inject imports from core-js when useBuiltIns: "usage" is enabled, meaning it scans your code and imports only the polyfills it thinks are needed for the target browsers.

Supply-chain caution: Polyfill.io in 2024

In February 2024 the polyfill.io domain and GitHub account moved to a new owner. In June 2024, security researchers and Cloudflare reported that scripts served from cdn.polyfill.io were injecting malicious JavaScript under certain conditions, including mobile redirects. Cloudflare and Fastly offered safer mirrors, and researchers recommended removing direct polyfill.io references. Treat any third-party script CDN as a supply-chain dependency: pin, audit, self-host when sensible, and keep ownership risk in mind.

Feature detection beats user-agent guessing

REAL OUTPUT

User-agent strings are messy: browsers spoof each other, enterprise builds lag, embedded WebViews patch unevenly, and capabilities can be disabled. Feature detection asks the environment directly: is the method present, does this syntax parse, or does CSS support a property-value pair?

Detect an API and a syntax featurePop 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());

Use typeof for globals, "name" in object for properties, and try/catch with new Function when you need to know whether syntax parses. For CSS, @supports and CSS.supports("container-type", "inline-size") answer the same kind of question in the style layer.

Do not eval learner input

Syntax probes use fixed strings that you wrote, not arbitrary user input. A page should never turn learner text, search queries, or server data into code with eval or new Function.

How this appears in real projects

A production build starts with a support policy: which browsers matter, how much legacy code you can ship, and whether your audience includes old WebViews. Then tooling turns that policy into transforms, polyfills, and delivery strategy.

  1. Write modern source and tests.
  2. Set targets with Browserslist, Baseline guidance, analytics, and product promises.
  3. Let Babel, SWC, TypeScript, or esbuild transpile syntax for those targets.
  4. Add audited polyfills or ponyfills for APIs your target browsers lack.
  5. Feature-detect risky paths and keep a fallback for features you cannot emulate.

Differential serving was once a common optimization: ship a modern bundle to browsers that understand type="module" and a legacy bundle to nomodule browsers. It is less central now that evergreen browsers dominate, but you will still see the pattern in older apps and compatibility discussions.

Historical module/nomodule splitHTML
<script type="module" src="/app-modern.js"></script>
<script nomodule src="/app-legacy.js"></script>

What cannot be transpiled or polyfilled?

Compatibility tools are powerful, not magical. A parser error can only be avoided by changing source before the browser sees it. A missing function can often be added. But some features depend on engine internals, security boundaries, garbage collection, or platform devices.

Choose the layer that can actually help
Kind of problemUsually fix withExamples
SyntaxTranspile before the browser parses it.Optional chaining, class fields, private methods, JSX, TypeScript types.
APIsPolyfill or ponyfill at runtime.Array.prototype.at, Promise, Object.groupBy, fetch in older browsers.
SemanticsOften cannot be perfectly fixed.Proxy traps, WeakRef lifetime behavior, precise module loading, some performance characteristics.
Platform behaviorFeature detect and design a fallback.CSS support, storage quotas, codecs, input devices, permission prompts.
Transpile, polyfill, or neither?
  • user.profile?.name ?? "Guest" must run in browsers that cannot parse ?..
  • list.at(-1) is missing, but the browser can parse the call.
  • A library depends on full Proxy interception semantics in IE11.
  • Object.groupBy(items, fn) is not present in an otherwise modern browser.
  • Template literals need to support an ES5-only embedded WebView.
  • Code relies on WeakRef garbage-collection timing.
Try it yourself
0 of 6 correct

Sort each compatibility problem by the layer 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. It needs polyfills for APIs and cannot recreate every engine feature.
  • “A polyfill can fix syntax.” If the browser cannot parse a token, no runtime code gets a chance to run.
  • “Baseline means I can ignore my users.” Baseline is a web-platform signal. Your analytics and support promises still matter.
  • “Usage-based polyfills require no review.” Generated imports are still dependencies. Review size, source, ownership, and what gets shipped.
  • “User-agent sniffing is good enough.” Capability checks survive forks, patches, and embedded browsers better than names and version strings.
Misconception checks
If you hearAskBetter habit
Just transpile itIs the failure syntax or an API?Map each feature to the layer that can change it.
Use a CDN polyfill serviceWho owns the script and can it change per request?Prefer trusted packages, pinned versions, and self-hosting when possible.
Target modern browsersWhich modern browsers and how old?Write a Browserslist query and verify with Baseline, caniuse, and analytics.

Practice exercises

5 EXERCISES
Exercise 1 · Warm-upDetect an existing API

Predict the output in the environments used by this lesson’s tests.

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 unsafe polyfill bugs

    Type the two compatibility bugs in the starter 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 ponyfill instead of a global patch

      Read the ponyfill and type the word printed by the console.

      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 · PracticeChoose the target transform

        Type the action the simplified target decision 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 installation strategy

          Should a polyfill installer rely on feature detection or 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 is the best plain definition of transpiling?

              Choose an answer to see the explanation.

            2. Question 2 of 7What does this feature detection snippet 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 reasonable production default to exclude dead browsers?

              Choose an answer to see the explanation.

            4. Question 4 of 7What should a safe polyfill do before defining a platform method?

              Choose an answer to see the explanation.

            5. Question 5 of 7Which feature belongs in the 'neither' bucket?

              Choose an answer to see the explanation.

            6. Question 6 of 7What does this target choice 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 was the lesson of the 2024 Polyfill.io incident?

              Choose an answer to see the explanation.

            Key takeaways

            • Transpilers are source-to-source compilers: parse, transform AST, generate JavaScript.
            • Syntax must be transpiled before old browsers parse it; APIs need runtime polyfills or ponyfills.
            • Browserslist expresses product support policy; Baseline and caniuse provide compatibility evidence.
            • Babel, SWC, TypeScript, and esbuild overlap, but their plugin ecosystems and outputs differ.
            • Safe polyfills feature-detect, avoid clobbering natives, define non-enumerable methods, and come from trusted supply chains.
            • Some features, such as full `Proxy` or `WeakRef` semantics, cannot be faithfully patched into old engines.

            Remember the one-liner.
            Transpile syntax, polyfill APIs, feature-detect behavior, and raise the browser floor when semantics cannot be recreated safely.

            Up next: Types for JavaScript, where JSDoc and TypeScript catch mistakes before compatibility tooling ships your code.

            CompleteFrontend Clear concepts. Working examples.