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?"
feature-detection.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
// 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, trueWorkers). 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
<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
{
"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 ofIntlgaps.