Definition
To run an app on old browsers you have to do two things, and they are different. One is transpiling: a machine rewrites new syntax (e.g. a?.b, a ?? b) into old syntax that old browsers can read. The other is polyfilling: re-implementing in JavaScript the functions/APIs that simply do not exist on old browsers (e.g. structuredClone(), Array.prototype.at) and filling them in.
The key point: a transpiler only rewrites syntax. It does not create missing runtime functions. So even after you have lowered all the syntax, the moment you call a missing function you get TypeError: ... is not a function.
Why It Matters
A common root of the "the app just dies wholesale for no reason" bug on old browsers is confusing these two. A team lowers only the syntax with @babel/preset-env, decides "old-browser support is done," and then the code dies the moment it calls structuredClone(). The syntax passed perfectly, but that function itself does not exist on the old browser.
Keeping the two problems separate speeds up diagnosis.
- Syntax problem: an old parser can't read new syntax, so the whole file dies with a
SyntaxError. After the first error, not one line of that script runs. - Runtime API problem: the syntax passes, but a
TypeErrorfires at the moment you call a missing function.
How It Works
- Declare the support floor (e.g. a specific old version) in one place — usually
browserslist. The transpile target and the polyfill target must share this value so they don't drift. - Syntax layer: given a target,
@babel/preset-envpicks out only the syntax that browser can't read and down-levels it. You must also route third-party code (node_modules) through the transform path so a library's?.is lowered too. - API layer:
core-js-compattakes the target list as input and computes "the list of standard APIs missing on those browsers." Use that list to build the polyfill bundle (proposal-stageesnext.*APIs are usually excluded). - Regression guard: force a test to check that the APIs the repo actually uses are included in the polyfill entry. Managing this by hand means newly used APIs get dropped.
// syntax: babel lowers this to old syntax (example below)
const name = user?.profile?.name ?? "guest";
// runtime API: babel does not create these → polyfill needed
const copy = structuredClone(data);
const last = list.at(-1);
const upper = text.replaceAll("a", "b");
Practical Application
- If the first console error is a
SyntaxError, look at the transpile config (target,node_modulesinclusion). - If it is
TypeError: X is not a function, check whether that API is in the polyfill bundle. - Let
core-js-compatderive the polyfill source from the target, not a human. - When you change the support floor, edit
browserslistand the polyfill build target together.
Trade-offs
| Choice | Upside | Cost |
|---|---|---|
| Transpile only | Simple config | Missing APIs unsolved → runtime death |
| Transpile + polyfill | Covers both syntax and API | Larger bundle, more build complexity |
| Hand-managed polyfills | No tooling needed | List drift, new APIs missed |
When Not to Use
- Permanently including heavy polyfills in a project that already targets only modern browsers — unnecessary cost.
- Blindly transpiling all of
node_modules, slowing the build — route only the offending packages through.
Common Mistakes
- Thinking "I added babel, so old-browser support is done" and dropping polyfills.
- Down-leveling only app code and leaving the new syntax of third-party libraries.
- Setting the transpile target and the polyfill target to different values so coverage drifts.
Related Concepts
- differential-polyfill-loading — Conditionally load polyfills only on old browsers
- hydration-failure-dead-handlers — When the bundle dies, hydration never fires and handlers all die
- browser-compat-interaction-smoke — A smoke test that actually catches syntax/API regressions