Polyfills vs Transpilation
Transpilation rewrites syntax an engine cannot parse. A polyfill supplies an API an engine does not have. Neither can do the other's job.
The intent, the obvious build, and why it breaks
Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.
The code works in my browser and throws in theirs. Do I need to transpile it, polyfill it, or neither?
Someone opens the site on a device a couple of years behind the one it was built on, and expects it to work rather than to show a blank page.
Set the build target low, add the big polyfill bundle, and stop worrying about it. Better safe than sorry.
Every modern user — the overwhelming majority — now downloads, parses and executes code that exists solely for browsers they are not using (The Real Cost of JavaScript).
- Every modern user — the overwhelming majority — now downloads, parses and executes code that exists solely for browsers they are not using (The Real Cost of JavaScript).
- Transpiled output is bigger and often slower than the source:
async/awaitcompiled down to a state machine is many times the size and defeats optimisations the engine would have applied to the original. - It still does not work, because the failure was a missing API and no amount of syntax rewriting adds one.
- Or the reverse: a polyfill was added for a missing method, and the file still fails to parse, because a parse error happens before any polyfill can run.
- "Better safe than sorry" has a measurable cost paid by every user on every visit, and it is usually paying for browsers with no measurable share.
What is actually happening
In the browser, not in the framework.
- Transpilation is a source-to-source transform. It takes syntax the target engine cannot *parse* — optional chaining, class fields,
async/await— and rewrites it into syntax that engine understands. It happens at build time and changes the shape of your code (The Module Graph). - A polyfill is runtime code that defines something missing.
Array.prototype.at,structuredClone,IntersectionObserver— the engine can parse the call fine, it just has nothing to call. The polyfill supplies an implementation before your code runs. - The distinction is parse time versus run time, and that is what makes them non-substitutable. A parse error kills the entire file before line one executes, so a polyfill inside that file can never help. A missing method is a runtime
TypeError, which no syntax transform can prevent. - A browserslist target is what makes either decision empirical: you declare which browsers you support, and the toolchain transpiles only what those browsers cannot parse and polyfills only what they lack.
- Modern toolchains can inject polyfills based on the APIs your code actually uses against that target, rather than including everything — which is the difference between a small, targeted set and a large generic bundle.
What this makes the browser do
And which of it is avoidable.
- Parsing and compiling transpiled output, which is larger than the source it replaced (The Real Cost of JavaScript).
- Executing polyfill code at startup, before your application runs, on the critical path.
- Running slower code paths: a transpiled generator or a polyfilled collection is generally slower than the engine's native implementation, and that cost is paid on every call.
- Downloading all of it, on every first visit, by users who needed none of it (Loading: Why Content Arrives Late).
Parse time or run time
The whole lesson collapses into one question: does the engine fail to *read* the code, or does it read it fine and then find nothing to call? The first is a syntax problem and only a transform fixes it. The second is an API problem and only an implementation fixes it.
The consequence that makes the distinction load-bearing is that a syntax error is fatal to the entire file, before any line of it executes. That is why a polyfill can never rescue a parse failure — it is inside a file that never ran.
| Problem | When it fails | What the error looks like | What fixes it |
|---|---|---|---|
a?.b on an engine without optional chaining | Parse time — before any execution | SyntaxError, entire file dead | Transpilation |
class A { #x = 1 } private fields | Parse time | SyntaxError, entire file dead | Transpilation |
await at module top level | Parse time | SyntaxError, entire file dead | Transpilation, or restructure |
arr.at(-1) | Run time, at the call | TypeError: arr.at is not a function | Polyfill |
structuredClone(x) | Run time, at the call | ReferenceError / TypeError | Polyfill |
new IntersectionObserver(...) | Run time, at construction | ReferenceError | Polyfill, or feature-detect and degrade |
CSS :has() unsupported | Style resolution — rule ignored | No error; the rule silently does nothing | Neither — write a fallback rule (The Cascade) |
1// app.js — targeting an engine without optional chaining2import './polyfills' // never runs3 4const name = user?.profile?.name // SyntaxError HERE5// ^ the whole file fails to parse. The import above it6// is irrelevant: nothing in this file ever executed.7 8// vs. a missing API — the file parses and runs until the call9const last = items.at(-1) // TypeError, only if reached10// ^ a polyfill loaded earlier defines Array.prototype.at11// and this line then worksOrdering cannot fix the first case. The failure is in the same file, and it happens before execution begins.
Deciding from data, not from caution
Because both tools cost the user something, the question is not "which do I add" but "what do my actual users need". A declared target turns an open-ended anxiety into a bounded, checkable configuration.
The temptation is to set it low and stop thinking. That is a real decision with a real bill, and it is worth being explicit that the bill is paid by everyone, forever, in exchange for supporting browsers that may have no measurable share.
Where does it fail, and who is affected?
when Syntax your declared target cannot parse.
cost Larger output, sometimes slower runtime paths, paid by every user including modern ones.
when An API used on the critical path that your target lacks.
cost Startup execution before your app runs, shipped to everyone regardless of need.
when An API needed only by older targets, and detectable.
cost A branch and an extra request on the legacy path; modern browsers fetch nothing (Lazy Loading).
when An enhancement rather than a requirement — smooth scrolling, an observer, a nicer input.
cost A second code path to maintain, and often the best answer: the baseline stays usable and nobody pays for a shim.
when Field data shows the affected browsers have negligible share.
cost Those users get nothing. Legitimate, but it must be a measured decision, not a default.
when A CSS feature that degrades gracefully on its own.
cost None — unsupported CSS is ignored rather than fatal, which is why CSS rarely needs either tool.
// every user, every visit import 'core-js' // the whole library import 'regenerator-runtime/runtime' // browserslist: "> 0.01%, ie 11" // -> transpiles async/await to state machines // -> ships hundreds of shims nobody calls
// browserslist from real field data
// -> polyfills injected only for APIs this code uses
// that this target actually lacks
// and for an enhancement, detect rather than assume:
if ('IntersectionObserver' in window) {
observeLazily()
} else {
loadEverythingEagerly() // baseline still works
}The second version ships nothing for capabilities the target already has, and keeps working where a capability is missing instead of throwing. The first taxes the majority to serve a minority it never measured — and the enhancement case shows the pattern that avoids both tools entirely.
How to build it
Most important first.
- Declare a real browserslist target based on your actual field data, not on a copied default. This single decision drives everything else in the lesson (Real User Monitoring).
- Transpile only what your target cannot parse. Every transform beyond that is size and speed spent on nothing.
- Polyfill by usage against that target rather than importing a blanket bundle.
- Prefer feature detection over assumption for anything optional, and degrade to a working baseline rather than throwing (The Browser Is a Runtime).
- Load rarely-needed polyfills conditionally — a dynamic import guarded by a detection check means modern browsers never fetch them at all (Lazy Loading).
- Re-examine the target periodically. A browserslist set three years ago is transpiling for browsers that no longer visit.
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- A parse error means no JavaScript runs at all, so an application that renders client-side shows nothing to anyone — and for assistive technology an empty document is not degraded, it is absent (Client-Side Rendering).
- Polyfill weight is paid disproportionately by users on older, slower devices, who overlap substantially with users who are already having a harder time. Shipping less to them is an accessibility improvement, not only a performance one.
- Where a feature genuinely cannot be polyfilled, the fallback must be usable rather than merely present: a non-functional control that is still focusable is worse than one that is properly disabled and labelled (Keyboard Operability).
What can go wrong
- A dependency shipping untranspiled modern syntax, so the app fails to parse on an older target even though your own source was transformed.
- A polyfill that is a partial implementation — it defines the name, so detection passes, and behaves differently in the case you actually hit.
- A polyfill that mutates a built-in prototype and breaks an unrelated library that was relying on the original behaviour.
- Feature detection written as a browser check rather than a capability check, which is wrong the moment the browser gains the feature.
- A parse error taking the whole bundle down, producing a blank page rather than a degraded one — the worst failure shape available (Frontend Error Tracking).
- A polyfill is third-party code executing before your application with full authority. Loading one from a CDN at runtime is a supply-chain decision, not a convenience (Third-Party Scripts and the Supply Chain).
- Prototype-patching polyfills change built-in behaviour globally, which has historically been a source of subtle vulnerabilities in code that trusted a built-in to behave as specified.
- Serving a legacy bundle to everyone means shipping the larger, older-idiom code path to every user, including whatever weaknesses its transforms introduce.
- "Babel polyfills my code." Transpilation and polyfilling are separate steps that happen to be configured in the same place. One rewrites syntax; the other supplies APIs.
- "A polyfill can fix a syntax error." It cannot. Parsing fails before any code in the file runs.
- "Transpiling adds the missing method." It rewrites syntax. A missing
Array.prototype.atis still missing afterwards. - "Target the oldest browser to be safe." Safe for a tiny minority, slower for everyone, and usually not measured at all.
- "Modern browsers auto-update, so this is solved." Not on every device, not in every market, and not for embedded or managed browsers.
Measuring it, and what changes in the field
- Browser and version distribution from field data — the input that makes the target a decision rather than a guess (Real User Monitoring).
- Bundle composition: how much of the initial chunk is polyfill and transform overhead (Bundle Analysis).
- Parse-and-compile time on a throttled profile, which is where transpilation overhead becomes visible (The Real Cost of JavaScript).
- Syntax errors in error tracking, grouped by browser — the unambiguous signal that the target is wrong for someone (Frontend Error Tracking).
- On a modern device the whole question is nearly invisible, which is why over-transpiling survives review.
- On an old device it is doubly expensive: more code to parse on a slower processor, and slower transpiled paths afterwards.
- In a market where older browsers have real share, the target is a product decision with a measurable cost either way — and the right answer is data, not instinct.
- A lower target reaches more browsers and taxes every modern user on every visit.
- A higher target is smaller and faster and excludes some users completely — which must be a deliberate, measured choice rather than an accident of configuration.
- Conditional polyfill loading keeps modern bundles clean and adds a request and a branch on the legacy path.
- Feature detection is more code than assuming, and it is the only approach that stays correct as browsers change.
Where this applies
Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.
- GENERALThe parse-time versus run-time distinction is a property of how engines load code, so it holds for every browser, bundler and toolchain and is not a convention that can be configured away.
- BROWSER-SPECIFICWhich syntax and which APIs are available differs by engine and version, and the gaps are not symmetrical — a browser can support recent syntax while lacking an older API, or the reverse — which is exactly why a declared target plus feature detection beats reasoning about "modern browsers".
- SPEC-EVOLVINGBoth baselines move constantly as engines ship features and old versions fall out of use, so a target set once becomes wrong in both directions over time: it transpiles for browsers that no longer visit and may miss ones that do.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Compilers & Programming Languages — a transpiler is a source-to-source compiler, and the size and speed cost of lowering modern syntax is a codegen question with the same shape as any other lowering.
- — Programming Languages & Runtime Internals — engines optimise idiomatic modern syntax directly, which is why transpiled output can be slower than the source it replaced rather than merely larger.