Shims and Polyfills
Date: 2026-08-17
A shim is code that sits in front of an existing interface to change or normalise its behaviour. A polyfill is a shim that supplies a standard API where it’s missing. Every polyfill is a shim; most shims are not polyfills — and the difference decides whether you can ever delete it.
The family
is the thing you need MISSING, or PRESENT-BUT-WRONG?
MISSING, and it's a standard → POLYFILL
Object.hasOwn on an old engine
MISSING, and you'd rather not
touch the global → PONYFILL
import it explicitly, patch nothing
PRESENT, but doesn't behave
how you need → SHIM
history.pushState fires no event
PRESENT, and you're changing it
with no discipline → MONKEY-PATCH
a shim you'd be embarrassed to explain
PRESENT, but the wrong SHAPE
for your code → ADAPTER / WRAPPER
translating one interface into another
The term “polyfill” was coined by Remy Sharp around 2010, specifically for the “missing standard API” case — before that everything here was called a shim. That history is why the two words get used interchangeably, and why being precise about them is worth the effort.
The test that separates them
Ask what happens when the platform catches up.
POLYFILL SHIM
the standard arrives the standard never arrives —
→ the polyfill becomes dead code the behaviour you wanted was
→ DELETE IT never specified
→ the shim is PERMANENT until you
temporary by design stop needing the behaviour
has an expiry condition
no expiry condition
A polyfill with no removal plan is technical debt with a timer on it. A shim is architecture — it’s part of how your system works, and it should be treated and documented as such rather than as a temporary patch — Technical Debt.
Worked: a shim
The canonical browser example, and the reason it’s a shim rather than a polyfill — history.pushState exists and works perfectly. It simply doesn’t fire an event, and it never will, because the specification doesn’t say it should.
// wrap the existing methods so a URL change is observable
['pushState', 'replaceState'].forEach((name) => {
const original = history[name];
history[name] = function (...args) {
const result = original.apply(this, args); // preserve behaviour
window.dispatchEvent(new Event('locationchange')); // add ours
return result;
};
});Nothing was missing. The behaviour was inadequate for a purpose the platform didn’t anticipate — The History API.
Worked: a polyfill
// a genuinely absent standard method, supplied conditionally
if (!Object.hasOwn) {
Object.hasOwn = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
}The if is the whole discipline. An unconditional assignment overwrites a native implementation that’s likely faster and definitely more correct — and it will keep overwriting it for years after the polyfill stopped being needed.
The risk of patching globals
Both shims and polyfills usually modify something you don’t own, and that has consequences worth pricing:
- Two libraries patching the same thing fight, and the loser is whichever loaded first. The failure is order-dependent and therefore intermittent
- The patch is invisible at the call site. Someone reading
history.pushState(...)has no way to know it’s been wrapped, which makes debugging genuinely hard — Abstraction and Leaky Abstractions - A slow or throwing patch poisons every caller, including third-party code that never asked for it
- Native implementations usually win on performance, so a permanent polyfill is a permanent tax
Ponyfills avoid all of this by exporting a function you import rather than patching a global. Where you control the call sites, prefer one — the only cost is that it doesn’t help code you don’t control.
Practical rules
- Always feature-detect, never version-detect.
if (!Object.hasOwn), never a user agent check — Browser Compatibility - Record why each polyfill exists and what removes it — a browserslist target, a dependency upgrade. Undocumented polyfills are never removed because nobody knows what they were for
- Load polyfills conditionally, so modern engines don’t pay for them. The build can do this from your target — Transpilation, Bundle Analysis
- Keep a shim in one named module, not scattered. A wrapped global defined in three files is a debugging nightmare
- Wrap, don’t replace. Call the original and add to it, as above, so you don’t silently drop behaviour someone depends on
- Review polyfills annually against your actual traffic. They accumulate and are rarely audited
Where it interacts
- Browser Compatibility — deciding what needs one, from real support data
- Transpilation — the build-time machinery that injects polyfills, and the syntax half it handles instead
- Abstraction and Leaky Abstractions — a shim is an abstraction over something you don’t own, and it leaks in the usual ways
- Backwards Compatibility — the same problem from the other side: shipping change without breaking consumers