BundlingGENERALBROWSER-SPECIFICSPEC-EVOLVING

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 question

The code works in my browser and throws in theirs. Do I need to transpile it, polyfill it, or neither?

The user intent

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.

The obvious build

Set the build target low, add the big polyfill bundle, and stop worrying about it. Better safe than sorry.

Why it breaks

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

How it breaks in a real browser
  • 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/await compiled 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.
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

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.

ProblemWhen it failsWhat the error looks likeWhat fixes it
a?.b on an engine without optional chainingParse time — before any executionSyntaxError, entire file deadTranspilation
class A { #x = 1 } private fieldsParse timeSyntaxError, entire file deadTranspilation
await at module top levelParse timeSyntaxError, entire file deadTranspilation, or restructure
arr.at(-1)Run time, at the callTypeError: arr.at is not a functionPolyfill
structuredClone(x)Run time, at the callReferenceError / TypeErrorPolyfill
new IntersectionObserver(...)Run time, at constructionReferenceErrorPolyfill, or feature-detect and degrade
CSS :has() unsupportedStyle resolution — rule ignoredNo error; the rule silently does nothingNeither — write a fallback rule (The Cascade)
Why a polyfill cannot save a parse error
1// app.js — targeting an engine without optional chaining
2import './polyfills' // never runs
3
4const name = user?.profile?.name // SyntaxError HERE
5// ^ the whole file fails to parse. The import above it
6// is irrelevant: nothing in this file ever executed.
7
8// vs. a missing API — the file parses and runs until the call
9const last = items.at(-1) // TypeError, only if reached
10// ^ a polyfill loaded earlier defines Array.prototype.at
11// and this line then works

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

Something is unsupported. What now?

Where does it fail, and who is affected?

Transpile it

when Syntax your declared target cannot parse.

cost Larger output, sometimes slower runtime paths, paid by every user including modern ones.

Polyfill it unconditionally

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.

Polyfill it conditionally

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

Feature-detect and degrade

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.

Raise the target and drop support

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.

Do nothing

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.

Shipping to everyone versus shipping what is needed
Blanket, uncalibrated
// 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
Targeted, and detected where optional
// 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

Failure modes
  • 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).
Security
  • 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.
Misreads
  • "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.at is 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

How you would see this
  • 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).
Slow device, slow network, large data, old tab
  • 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.
What this costs
  • 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.

Domains that do not exist yet
  • 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.