JavaScriptBrowser Compatibility

Browser Compatibility

Modern browsers agree on far more than they used to, but they are not identical and they are not all on the same version. "Will this work for my users?" is a question with a real answer if you know where to look and how to write code that degrades gracefully when a feature is missing.

The first stop: caniuse.com

Before you adopt a new API, search it on caniuse.com. The site shows a grid of browsers and versions with red, yellow and green cells, plus a global usage percentage. MDN's "Browser compatibility" tables show the same information per API.

  • Decide your support target first — for example, "the last two versions of Chrome, Firefox, Safari and Edge" or a Browserslist string like > 0.5%, last 2 versions, Firefox ESR, not dead.

  • A feature is "safe" when every browser in your target shows green for it.

  • Yellow cells (partial support) often hide the real bugs — read the notes carefully.

Feature detection beats UA sniffing

The old habit of reading navigator.userAgent to decide what code to run is brittle and wrong. User agents lie, get spoofed, and change. Ask the right question: "does this browser have the thing I need?"

Do not do this
Do not branch on `navigator.userAgent.includes('Chrome')`. You will eventually mis-detect a perfectly capable browser that happens to have a different UA string.

feature-detection.js

JS
// Does this runtime support what I want?
if ('IntersectionObserver' in window) {
  setupLazyImages();                    // use the API
} else {
  loadAllImagesEagerly();               // fall back
}

if (typeof structuredClone === 'function') {
  return structuredClone(value);
} else {
  return JSON.parse(JSON.stringify(value));   // crude but compatible
}

// CSS too — does the browser understand this property/value?
if (CSS.supports('display', 'grid')) {
  enableGridLayout();
}
Polyfills — fill the gap, do not invent it

A polyfill adds a missing feature so your single codepath works everywhere. Use one when the feature is behaviourally identical across implementations; if the polyfill is "almost right", you have two bugs to debug instead of one.

polyfill.js

JS
// Add the method only if it is missing
if (!Array.prototype.at) {
  Array.prototype.at = function (n) {
    n = Math.trunc(n) || 0;
    if (n < 0) n += this.length;
    return n < 0 || n >= this.length ? undefined : this[n];
  };
}
  • Prefer well-known polyfill libraries (core-js, polyfill.io) over hand-rolled ones for anything non-trivial.

  • Load polyfills conditionally — do not ship them to browsers that have the feature.

  • Some APIs cannot be polyfilled (WebGL, WebRTC, true Workers). Those need feature detection + a fallback experience.

Progressive enhancement

Build the simple thing first, then add the fancy bits in layers. Each layer assumes the previous one works, but the previous one does not depend on the next. The page is useful at every level.

  • Layer 1 — HTML. A real form that posts to a real URL. Submits even with JS disabled.

  • Layer 2 — CSS. Visual polish. No layout-breaking dependencies.

  • Layer 3 — JavaScript. Intercept the submit, validate inline, animate transitions. If JS fails, layer 1 still works.

progressive.html

HTML
<form action="/subscribe" method="post">
  <label for="email">Email</label>
  <input id="email" name="email" type="email" required>
  <button type="submit">Subscribe</button>
</form>

<script>
  // Enhance: intercept and submit via fetch, show inline feedback.
  // If the script fails to load, the form still works as plain HTML.
  document.currentScript.previousElementSibling
    .addEventListener('submit', enhanceSubmit);
</script>
Transpilation and your build target

You can write the latest JavaScript and let a transpiler (Babel, SWC, esbuild, TypeScript) lower the syntax to something older browsers understand. Browserslist is the small config that ties every tool in your pipeline to the same list of target browsers.

package.json

JSON
{
  "browserslist": [
    "> 0.5%",
    "last 2 versions",
    "Firefox ESR",
    "not dead"
  ]
}
  • Babel/SWC use this list to pick which syntax to transform (e.g. lower ?? and optional chaining for older Safaris).

  • Autoprefixer uses it to add vendor CSS prefixes only where they are still required.

  • core-js (with @babel/preset-env) injects polyfills only for features missing in your targets.

  • Modern build tools also support differential serving: ship a small modern bundle to evergreen browsers and a larger transpiled one to old IEs.

Mobile, iOS Safari and the long tail

The single most common compat surprise on the web is "works on desktop, broken in iOS Safari". Older iOS versions update slowly because they ride along with the OS, and iOS forces every browser engine to be WebKit. Test there early, not at release.

  • On macOS, switch the User Agent in Safari’s Develop menu — close, but not a real device.

  • Real iOS Simulator (Xcode) or a paid service like BrowserStack catches the layout bugs.

  • Common offenders: viewport units, 100vh, sticky positioning, autoplaying audio, date inputs, and a long history of Intl gaps.

A workflow that scales
When to reach for what
First **check caniuse**, then **feature-detect** in code, then **polyfill or fall back** if usage is significant, and let your **build target** handle the rest. UA sniffing is a last resort for specific browser bugs you cannot detect any other way — and even then, isolate it to one tiny module so it is easy to delete later.
The mindset
Browser compat is less about memorising tables and more about asking, every time you reach for a shiny API, "what happens if it is not here?" Once that question is automatic, the rest is just tooling.