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 JavaScript you write may not match the syntax and APIs your users' browsers understand.
- 02Choose the right fixDecide whether a change needs transpiling, a polyfill, a ponyfill, feature detection, or a fallback.
- 03Read 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.
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.
| Tool | What it changes | Example |
|---|---|---|
| Transpiler | Rewrites source syntax before users download it. | Turns items?.[0] ?? fallback into older JavaScript guards for targets that cannot parse it. |
| Polyfill | Adds a missing runtime API, usually on a global or prototype. | Defines Array.prototype.at when an older browser lacks it. |
| Ponyfill | Exports a helper instead of changing globals. | Imports arrayAt(list, -1) from a package and calls that helper directly. |
| Feature detection | Checks 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.
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.
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 THROUGHA 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.
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.
script
const label = settings.label ?? "Untitled";console.log(label);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
PLAYGROUNDCompilers 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.
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)));TypeScript rewrites arrows, classes, spread, template literals, defaults, optional chaining, and nullish coalescing because ES5 browsers parse none of that syntax.
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.
# package.json
"browserslist": [
"defaults",
"> 0.5%",
"last 2 versions",
"not dead"
]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
TOOLSBabel 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.
| Tool | Strength | Common fit |
|---|---|---|
| Babel | Plugin ecosystem and @babel/preset-env. | Applications that need specific transforms, JSX plugins, or usage-based core-js injection. |
| SWC | Rust compiler used by Next.js for fast transforms. | Next.js projects, TypeScript stripping, JSX, minification, and common syntax downleveling. |
| TypeScript compiler | Strips types and can downlevel syntax by target. | Libraries or tests that need a deterministic compiler output without Babel in the browser. |
| esbuild | Very 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.
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 TypeScript types and lowers JSX to React.createElement, but with disableESTransforms it leaves optional chaining and nullish coalescing in place.
// 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 THROUGHOnce 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.
A safe polyfill checks for the feature, installs only when missing, and defines the method without making it enumerable.
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"));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.
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 OUTPUTUser-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?
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.
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.
- Write modern source and tests.
- Set targets with Browserslist, Baseline guidance, analytics, and product promises.
- Let Babel, SWC, TypeScript, or esbuild transpile syntax for those targets.
- Add audited polyfills or ponyfills for APIs your target browsers lack.
- 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.
<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.
| Kind of problem | Usually fix with | Examples |
|---|---|---|
| Syntax | Transpile before the browser parses it. | Optional chaining, class fields, private methods, JSX, TypeScript types. |
| APIs | Polyfill or ponyfill at runtime. | Array.prototype.at, Promise, Object.groupBy, fetch in older browsers. |
| Semantics | Often cannot be perfectly fixed. | Proxy traps, WeakRef lifetime behavior, precise module loading, some performance characteristics. |
| Platform behavior | Feature detect and design a fallback. | CSS support, storage quotas, codecs, input devices, permission prompts. |
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
Proxyinterception 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
WeakRefgarbage-collection timing.
Sort each compatibility problem by the layer that can solve it.
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.
| If you hear | Ask | Better habit |
|---|---|---|
| Just transpile it | Is the failure syntax or an API? | Map each feature to the layer that can change it. |
| Use a CDN polyfill service | Who owns the script and can it change per request? | Prefer trusted packages, pinned versions, and self-hosting when possible. |
| Target modern browsers | Which modern browsers and how old? | Write a Browserslist query and verify with Baseline, caniuse, and analytics. |
Practice exercises
5 EXERCISESPredict the output in the environments used by this lesson’s tests.
const supportsAt = typeof Array.prototype.at === "function";
console.log(supportsAt ? ["first", "last"].at(-1) : "fallback");The feature check is true, so the code calls at(-1) and prints last.
Type the two compatibility bugs in the starter 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));The code clobbers a native method and implements only direct property lookup, so negative indexes fail. A safe polyfill feature-detects first and implements the specified index rules.
Read the ponyfill and type the word printed by the console.
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));The helper computes the last index and returns last without changing any global object.
Type the action the simplified target decision prints.
const source = "const value = options.timeout ?? 1000";
const target = "ES2019";
console.log(target === "ES2020" ? "keep ??" : "rewrite ??");For an ES2019 target, the compiler should rewrite ??, so the snippet prints rewrite ??.
Should a polyfill installer rely on feature detection or user-agent sniffing?
if (!("at" in Array.prototype)) { /* define it */ }Feature detection asks for the capability directly. Then a polyfill can install a non-enumerable method without overwriting native code.
Check your understanding
7 QUESTIONSQuestion 1 of 7Which sentence is the best plain definition of transpiling?
Choose an answer to see the explanation.
Question 2 of 7What does this feature detection snippet 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 reasonable production default to exclude dead browsers?
Choose an answer to see the explanation.
Question 4 of 7What should a safe polyfill do before defining a platform method?
Choose an answer to see the explanation.
Question 5 of 7Which feature belongs in the 'neither' bucket?
Choose an answer to see the explanation.
Question 6 of 7What does this target choice 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 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.