What This Post Covers
This sums up what I learned fixing a bug where all interactions on a page die on a browser more than five years old. I leave out specific products and numbers and cover only general concepts that hold anywhere. The biggest takeaway was breaking the misconception that "old-browser support = babel alone" — in reality it is a multi-layer contract where syntax down-leveling, runtime polyfills, conditional loading, CSS fallbacks, and a real-browser gate cooperate.
What I Covered Today
| Task | What I wanted | What I did | Result |
|---|---|---|---|
| Simultaneous-death diagnosis | Find why several features die at once | Saw all three were event handlers, pinned it to hydration failure | Not 3 bugs but a single root cause |
| Syntax vs API | Keep the code alive on old browsers | Lowered syntax with babel, filled missing functions with polyfills | Confirmed syntax pass ≠ API coverage |
| Conditional polyfill load | Give polyfills without burdening modern browsers | Synchronously loaded only on old browsers via feature-detect | Modern-browser cost ≈ 0 |
| Smoke reinforcement | Actually catch the regression | Added click/submit interactions + a broad console filter | Removed the render-only smoke's blind spot |
1. When Several Features Die at Once, Suspect Hydration
What the situation was. On one page, the toggle, the account login, and submit all three stopped working. At a glance it looked like three bugs, but what they had in common was that they were all click/submit event handlers.
Core concept. Hydration is the process where the browser later attaches events via JavaScript to the static HTML the server produced, bringing it alive. Until that finishes, a button is just a picture. So if the client JS can't run at all (a syntax parse failure or a call to a missing API), every handler on that screen dies at once. The screen itself, being server HTML, looks fine, which is more confusing.
What I did. I hypothesized the several symptoms as "one execution failure" rather than "N bugs" and looked at the first console error. That it reproduced only on a specific old browser also pointed to a compatibility cause.
Lesson. When several UI features die at once, first suspect a common root cause like hydration/bundle parsing rather than separate bugs.
→ Hydration Failure and Dead Event Handlers
2. Syntax Down-Leveling and Runtime Polyfills Are Separate Layers
What the situation was. The cause of the code dying on old browsers was not one thing. Even after lowering the syntax, points that still died remained.
Core concept. You must distinguish two problems. (1) New syntax (a?.b, a ?? b) can't be read by old parsers, so the whole file dies with a SyntaxError — solved by transpiling (babel) down to old syntax. (2) Missing runtime functions (structuredClone, Array.prototype.at, String.prototype.replaceAll) are not created by transpiling — the moment you call one you get is not a function. This is solved only by polyfilling (re-implementing the missing function in JS and injecting it). Passing syntax is not API coverage.
What I did. For syntax I gave @babel/preset-env a target and down-leveled even third-party code (node_modules); for missing APIs I let core-js-compat compute the missing list from the target and build the polyfill bundle. Since a hand-managed polyfill list drifts, I forced a regression test to check the existence of actually-used APIs.
Lesson. Always think of syntax down-leveling and runtime APIs separately. If the first console error is a SyntaxError it's syntax; if it's TypeError: is not a function it's an API problem.
→ Syntax Transpilation vs Runtime Polyfill
3. Load Polyfills Only on Old Browsers
What the situation was. A polyfill bundle is tens of KB, and giving it to every visitor penalizes even modern-browser users (the majority) who need no polyfill.
Core concept. Feature detection is actually testing "does this browser have this feature?" like 'at' in Array.prototype (more robust than guessing from the UA string). A small synchronous script in <head> checks representative APIs and synchronously loads the polyfill only when any one is missing. The detection cost is a few dozen bytes, so the modern-browser cost is effectively zero.
What I did. I chose "build-time-generated bundle + feature-detect conditional load." Static inclusion in the entry is a constant cost to modern browsers, and UA-based server-side dynamic generation needs server ops, which was overkill. Since the target was effectively an "old vs new" split, conditional loading was optimal. I served the polyfill from the same origin rather than an external CDN (supply-chain-attack history, availability).
Lesson. Load polyfills only on old browsers via feature-detect to make the modern-browser cost near zero. The detection script must run before the app bundle.
→ Feature-Detected Differential Polyfill Loading
4. A Compatibility Smoke Must Run Real Interactions
What the situation was. The existing smoke test failed to catch this bug and passed. I was curious why it passed.
Core concept. A runtime TypeError only fires at the moment the code actually runs. If the smoke checks only rendering (row counts, text), the handler that actually breaks never runs once, so it passes green. Also, a browser more than five years old can't be reproduced on a current automation tool's engine, so you must download that version's browser/driver binaries at pinned versions and run them.
What I did. I added per-page click/toggle/submit/routing interactions and put referenceerror, is not a function, is not defined into the console-error filter while excluding the network-flavored Failed to fetch as a false positive. Since the polyfill existence check and the interaction test have different roles, I separated them.
Lesson. A smoke that checks only rendering passes even if all handlers are dead. You need real click/submit interactions + a broad console-error filter to catch the regression.
→ Browser-Compat Interaction Smoke Test