🌐 Browser Compatibility
Transpilation gets your syntax to run everywhere, but shipping robust software across browsers is a bigger strategy: knowing your real audience, checking whether a feature is safe to use, detecting capabilities at runtime, and building experiences that hold up even when something isn't supported. Think of it like a universal remote that must work with a 1990s TV and a brand-new smart panel alike.
Week 3 · Day 4 (Thursday: Babel and Transpilation) · Lecture 3
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Decide real browser targets using caniuse data and analytics instead of guesswork
- Explain why feature detection beats browser (user-agent) sniffing
- Feature-detect JavaScript APIs and CSS with
in,typeof, and@supports/CSS.supports() - Load polyfills conditionally so modern browsers don't pay for legacy support
- Contrast progressive enhancement with graceful degradation and apply each
- Set up practical cross-browser testing with a matrix and tooling like Playwright
Estimated Time: 60 minutes
Practice: Build a feature-detected, conditionally-polyfilled loader and a progressively-enhanced form.
In This Lesson
The Compatibility Problem
Every browser ships a different JavaScript engine, updates on its own schedule, and supports a slightly different slice of the web platform. Your users span all of that variety at once. The goal isn't to make every browser identical — it's to make sure everyone gets a working experience, with nicer experiences for more capable browsers.
| Browser | JavaScript engine | Character |
|---|---|---|
| Chrome / Edge | V8 | Fast, quick to adopt new features |
| Firefox | SpiderMonkey | Strong standards compliance |
| Safari (incl. all iOS browsers) | JavaScriptCore | Sometimes slower to ship features; the real "edge case" today |
| Legacy IE 11 | Chakra | Very limited ES6+; only relevant for specific enterprise audiences |
💡 The modern reality
IE is retired, and the big engines are "evergreen" (auto-updating). The compatibility gap that used to be "modern vs IE" is now subtler: a feature might be in Chrome for a year before it reaches Safari, and iOS users are locked to Safari's engine. So compatibility is less about ancient browsers and more about the newest features not being universal yet.
Deciding Your Targets
Before writing any fallback, answer one question: who actually visits this site? Guessing leads to either broken experiences or bloated bundles. Two data sources give you the real answer.
real visitor browsers] --> C[Target list
Browserslist query] B[caniuse.com
feature support tables] --> D{Is a feature
safe for those targets?} C --> D D -->|Yes| E[Use it directly] D -->|No| F[Transpile / polyfill /
feature-detect]
1. Analytics tell you the "who"
Your own traffic data (or a reasonable industry default for a new project) tells you which browsers and versions matter. Feed that into a Browserslist query — the same one Babel and Autoprefixer already read.
2. caniuse tells you the "can I"
caniuse.com is the definitive lookup for "does browser X support feature Y, and since when?" Check a feature there before relying on it. The same data powers Browserslist queries like "supports es6-module".
# See exactly which browsers your query resolves to today
npx browserslist "> 0.5%, last 2 versions, not dead"
# Or query by capability, straight from caniuse data
npx browserslist "supports es6-module"
✅ Target the audience you can prove, not the audience you imagine
Every legacy browser you add to your target list makes bundles bigger and forces more transforms/polyfills on everyone. Support what your data shows; revisit the query periodically as usage shifts.
Feature Detection, Not Sniffing
There are two ways to ask "can this browser do X?" One is reliable; the other is a trap.
- ❌ Browser sniffing: read
navigator.userAgent, guess the browser, assume its capabilities. UA strings lie, get spoofed, and change constantly — you end up maintaining a brittle lookup table forever. - ✅ Feature detection: ask directly whether the specific capability exists right now. If it's there, use it; if not, fall back. It never goes stale.
Detecting JavaScript APIs
// Does a global / method exist?
const hasFetch = 'fetch' in window;
const hasPromise = typeof Promise !== 'undefined';
const hasIO = 'IntersectionObserver' in window;
// Branch on the result:
if (hasFetch) {
const res = await fetch('/api/data');
const data = await res.json();
} else {
// fall back to XMLHttpRequest, or load a fetch polyfill
}
Detecting CSS support
The @supports at-rule (and its JS twin CSS.supports()) tests CSS features. Wrap enhancements so unsupported browsers simply skip them and keep the base styles.
/* Base layout that works everywhere */
.gallery { display: block; }
/* Upgrade to grid only where it's supported */
@supports (display: grid) {
.gallery {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1rem;
}
}
// Same test from JavaScript
if (CSS.supports('display', 'grid')) {
container.classList.add('has-grid');
}
⚠️ Old tutorials still show UA sniffing — don't copy it
Snippets that check navigator.userAgent for "Chrome" or "Safari," or use IE conditional comments, are a maintenance liability. If you truly must special-case one browser's bug (rare), isolate it and comment why. Default to feature detection.
Conditional Polyfills
As you learned with Babel, missing APIs need polyfills. The goal here is to load them only when needed, so modern browsers don't download code they'll never run. Feature detection + dynamic import() makes that clean.
// Load only the polyfills this particular browser is missing.
async function loadPolyfills() {
const jobs = [];
if (!('Promise' in window)) {
jobs.push(import('promise-polyfill/src/polyfill'));
}
if (!('fetch' in window)) {
jobs.push(import('whatwg-fetch'));
}
if (!('IntersectionObserver' in window)) {
jobs.push(import('intersection-observer'));
}
// A fully modern browser downloads none of these.
await Promise.all(jobs);
}
loadPolyfills().then(startApp);
💡 Two ways to polyfill — pick per situation
- Build-time via Babel + core-js (
useBuiltIns: 'usage'): great for the APIs your own source uses. Set once, forget. - Runtime conditional import (above): great for expensive or rarely-needed polyfills you want to lazy-load only for the browsers that lack the feature.
They combine well: let Babel handle the common cases and hand-load the heavy outliers.
Enhancement vs Degradation
Two philosophies describe how to structure a cross-browser experience. They aim at the same place from opposite directions.
Progressive enhancement (recommended default)
Build the working baseline first, then enhance where the browser allows. Because the foundation is plain HTML, the app is usable even if JavaScript fails to load — which is also great for accessibility and SEO.
<!-- 1. Works with zero JS: a real form that posts to the server -->
<form id="contact" action="/submit" method="POST">
<label for="email">Email</label>
<input type="email" id="email" name="email" required>
<button type="submit">Send</button>
</form>
// 2. Enhance: intercept and submit via fetch IF available.
const form = document.querySelector('#contact');
if (form && 'fetch' in window) {
form.addEventListener('submit', async (e) => {
e.preventDefault();
try {
const res = await fetch(form.action, {
method: 'POST',
body: new FormData(form)
});
if (res.ok) showMessage('Sent!');
else form.submit(); // fall back to normal POST
} catch {
form.submit(); // network issue → let the browser do it
}
});
}
// A browser without fetch (or with JS off) just does a normal form POST.
Graceful degradation
Sometimes you start from a rich experience and add fallbacks. Detect the capabilities you depend on; if they're missing, drop to a simpler-but-functional mode.
class ImageGallery {
constructor(el) {
this.el = el;
if (this.canGoAdvanced()) this.initAdvanced();
else this.initBasic();
}
canGoAdvanced() {
return 'IntersectionObserver' in window
&& CSS.supports('display', 'grid');
}
initAdvanced() { /* lazy-load + grid + animations */ }
initBasic() { /* load all images, simple prev/next */ }
}
✅ Which to choose?
Prefer progressive enhancement when you can — a resilient baseline is better for accessibility, reliability, and search engines. Reach for graceful degradation when a feature is inherently rich (a canvas game, a video editor) and a plain-HTML baseline isn't meaningful.
Cross-Browser Testing
Detection and fallbacks only count if you verify them. You don't need every browser on earth — you need a deliberate matrix covering your real targets, tested both manually and automatically.
1. Define a small, honest matrix
| Engine family | Test on | Why it's on the list |
|---|---|---|
| Chromium (V8) | Latest Chrome + Edge | Largest share; the "happy path" |
| Gecko (SpiderMonkey) | Latest Firefox | Independent engine; catches Chrome-only assumptions |
| WebKit (JavaScriptCore) | Safari + any iOS browser | The most common real-world compatibility gaps today |
2. Automate with Playwright
Playwright drives all three engine families (Chromium, Firefox, WebKit) from one script — the modern standard for cross-engine end-to-end testing.
// smoke.spec.js — run the same check across every engine
const { chromium, firefox, webkit } = require('playwright');
async function smokeTest() {
for (const engine of [chromium, firefox, webkit]) {
const browser = await engine.launch();
const page = await browser.newPage();
await page.goto('http://localhost:3000');
await page.fill('#email', 'test@example.com');
await page.click('button[type="submit"]');
await page.waitForSelector('.success'); // fails loudly if broken
console.log(`✓ passed on ${engine.name()}`);
await browser.close();
}
}
smokeTest();
📖 When to add a device-cloud
For real hardware (older phones, specific OS/Safari combos) a service like BrowserStack or Sauce Labs runs your suite on actual devices. Start with local Playwright; add a device-cloud when your analytics show meaningful traffic you can't reproduce locally.
Practice & Quiz
🏋️ Exercise 1: A safe capability check
Goal: Write runWhenReady() that uses IntersectionObserver for lazy-loading if present, and otherwise loads a polyfill via dynamic import() before continuing — so modern browsers download nothing extra.
💡 Hint
Test 'IntersectionObserver' in window. If false, await import('intersection-observer'). Then call your setup either way.
✅ Solution
async function runWhenReady() {
if (!('IntersectionObserver' in window)) {
await import('intersection-observer'); // only old browsers pay for this
}
setupLazyLoading(); // safe now — the API is guaranteed to exist
}
runWhenReady();
🏋️ Exercise 2: Progressively enhance a link
Goal: A <a href="/report.pdf"> should always work as a normal download. Enhance it so that, when fetch exists, clicking shows an inline preview instead — but a browser without fetch still gets the plain download.
✅ Solution
const link = document.querySelector('a[href="/report.pdf"]');
if (link && 'fetch' in window) {
link.addEventListener('click', async (e) => {
e.preventDefault();
try {
const res = await fetch(link.href);
const blob = await res.blob();
showPreview(URL.createObjectURL(blob));
} catch {
window.location.href = link.href; // fall back to the real download
}
});
}
// No fetch / JS disabled? The anchor's default behavior downloads the file.
🎯 Quick Quiz
Question 1: Why is feature detection preferred over reading navigator.userAgent?
Question 2: Which CSS tool tests whether a browser supports a property before you rely on it?
Question 3: What is the core idea of progressive enhancement?
Best Practices & Pitfalls
✅ Do
- Set targets from real analytics + caniuse, and keep them in one Browserslist config
- Feature-detect capabilities; branch to native or fallback
- Load polyfills conditionally so modern browsers stay lean
- Default to progressive enhancement for a resilient, accessible baseline
- Test a deliberate matrix across all three engine families (Chromium, Gecko, WebKit)
❌ Don't
- Sniff
navigator.userAgentto decide capabilities - Ship every polyfill to every visitor "just in case"
- Assume "works in Chrome" means "works everywhere" — always check WebKit/Safari
- Rely on JavaScript for core functionality that HTML could deliver
- Support ancient browsers with no measured traffic — it taxes everyone
⚠️ The "it works on my machine" trap
Developing in one modern browser hides gaps. Safari/WebKit is where most real-world surprises live today (and iOS forces every browser onto it). Make WebKit a first-class part of your test matrix, not an afterthought.
Summary
🎉 Key Takeaways
- Choose targets from real data (analytics + caniuse), expressed as a Browserslist query
- Feature-detect capabilities — never sniff the user agent
- Load polyfills conditionally so modern browsers don't pay for legacy support
- Progressive enhancement gives a resilient baseline; graceful degradation adds fallbacks to rich apps
- Verify with a cross-engine test matrix (Chromium, Gecko, WebKit) using tools like Playwright
📚 Additional Resources
- Can I use — browser feature support tables
- MDN — feature detection
- MDN — progressive enhancement
- MDN — the @supports at-rule
🚀 What's Next?
That wraps up the tooling arc — modules, bundlers, Babel, and compatibility. Next you shift from shipping code to trusting it: Introduction to Jest, where you'll write automated tests that prove your JavaScript behaves the way you intend, in every environment.
🎉 Ships everywhere!
You can now reason about who your code runs for, prove what a browser can do, and design experiences that hold up across the whole web. That's what separates a demo from a product.