What Is a Polyfill in JavaScript?
A polyfill is code that implements a missing browser feature so older environments behave as if they supported it natively.
A polyfill is a piece of JavaScript that reimplements a feature the browser running it doesn’t natively support, so application code can call that feature as if it were built in. The term comes from Polyfilla, a UK brand of spackling paste used to fill cracks and gaps — a polyfill fills the gap between what a browser’s JavaScript engine actually implements and what the language or platform spec says should exist.
Why gaps exist in the first place
Browsers don’t all ship the same features at the same time. A new array method, a new global object, or a new Web API can land in one engine months or years before it lands in the others — and users on older browser versions, corporate-locked installs, or less common platforms may never update at all. If your code calls Array.prototype.flat() and it runs in an environment that predates that method, the call throws a TypeError, not a graceful fallback.
A polyfill closes that gap by detecting whether the native feature exists and, if it doesn’t, defining it in plain JavaScript with equivalent behavior:
if (!Array.prototype.flat) {
Array.prototype.flat = function (depth = 1) {
return depth > 0
? this.reduce(
(acc, val) =>
acc.concat(Array.isArray(val) ? val.flat(depth - 1) : val),
[]
)
: this.slice();
};
}
After this runs, [1, [2, 3]].flat() works the same whether the engine implemented flat natively or not. Application code never needs to know which case it’s in.
Polyfills vs transpilers vs shims
These three terms get used loosely and interchangeably, but they solve different problems:
| Polyfill | Transpiler | Shim | |
|---|---|---|---|
| What it changes | Adds a missing runtime API | Rewrites syntax to an older equivalent | Adapts one API’s interface to match another |
| Example | Array.prototype.flat | Arrow functions → function expressions | localStorage API backed by cookies |
| Runs | At runtime, in the browser | At build time, before shipping | At runtime |
| Fixes | Missing built-ins | Missing syntax, which can’t be polyfilled | Interface mismatches, not necessarily missing features |
A transpiler like Babel handles new syntax — optional chaining, async/await, class fields — by rewriting it into older syntax the target engine can parse. That’s a fundamentally different job from a polyfill: syntax can’t be polyfilled, because a polyfill is just a function, and you can’t define new grammar with a function call. A polyfill only works for features expressible as regular objects, functions, or methods — things you can attach to an existing global or prototype.
Feature detection, not browser sniffing
A well-written polyfill checks whether the feature is already present before defining it, as in the flat example above. This is feature detection: ask “does this thing exist” directly, rather than trying to infer capability from a user-agent string. Browser sniffing is fragile — user agents lie, get spoofed, and change format — while feature detection asks the actual question you care about.
This same principle shows up elsewhere on the platform. Progressive enhancement is built on the same idea at a larger scale: ship a baseline experience that works everywhere, then layer on enhancements only where the underlying capability is confirmed to exist, whether that’s a polyfilled method or a modern CSS feature behind an @supports check.
Where polyfills fit in a modern build
Loading every possible polyfill unconditionally is wasteful — most visitors run modern browsers that need none of it, and shipping the code anyway costs bytes and parse time for nothing. Two patterns address this:
- Differential serving. Serve a modern, polyfill-free bundle to browsers that support current syntax and APIs (often via
<script type="module">, which most legacy browsers won’t even parse), and a separate, polyfilled, transpiled bundle to everything else via<script nomodule>. - Targeted polyfill services. A service reads the requesting browser’s user agent, computes exactly which features that browser is missing, and returns only those polyfills. This trades a browser-sniffing dependency for meaningfully smaller payloads on modern browsers.
Either approach connects to the same critical rendering path concerns as any other blocking script: a polyfill bundle loaded synchronously in <head> delays everything after it, so it’s worth being deliberate about what actually needs to load before your application code runs.
Do you still need polyfills today
Fewer than a few years ago, but the need hasn’t disappeared. Modern JavaScript engines converge on new features faster than they used to, and Bun, Node, and browser evergreen updates mean the gap between “specified” and “shipped” is smaller. But polyfills are still relevant for:
- Genuinely new platform APIs in their first year or two, before every target browser has caught up.
- Environments you don’t fully control — embedded webviews, older enterprise browsers, some smart TVs and kiosks.
- Server-side or edge runtimes that don’t implement every browser global, where a polyfill lets isomorphic code run unmodified in both places.
Package managers make this easier to manage than hand-writing detection code: install a maintained polyfill package via npm, pnpm, or yarn rather than pasting one in from a blog post, since edge cases in spec compliance are easy to get subtly wrong by hand.
The takeaway
A polyfill fills a runtime gap by reimplementing a missing built-in feature in plain JavaScript, checked with feature detection so it only activates where the native version is actually absent. It’s distinct from a transpiler, which rewrites syntax rather than adding runtime APIs. The practical move today is to avoid shipping polyfills unconditionally — target them at the browsers that actually need them, whether through differential bundles or a polyfill service, so modern visitors aren’t paying a tax for compatibility they don’t require.
Tagged
Keep reading
Takina · · 4 min read Object.groupBy() in JavaScript, Explained
Object.groupBy() buckets array items by a key you compute, replacing the reduce() boilerplate developers have written for years. How it works.
Takina · · 4 min read Web Locks API: Coordinating Work Across Browser Tabs
The Web Locks API lets JavaScript acquire named locks shared across tabs, so only one tab does work like a token refresh or a write at a time.
Takina · · 4 min read Svelte 5 Runes Explained
Svelte 5 runes like $state and $derived replace the old reactive-assignment magic with explicit function calls that work anywhere in a file.