Skip to main content

🌐 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.

BrowserJavaScript engineCharacter
Chrome / EdgeV8Fast, quick to adopt new features
FirefoxSpiderMonkeyStrong standards compliance
Safari (incl. all iOS browsers)JavaScriptCoreSometimes slower to ship features; the real "edge case" today
Legacy IE 11ChakraVery 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.

graph LR A[Your analytics
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.
Feature detection asks whether a capability exists and branches to native or fallback 'fetch' in window ? ask the capability directly Yes → use fetch() the modern path No → XHR fallback or load a polyfill
Detect the feature, then branch. The question is about the capability, never about which browser is asking.

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.

graph TD A[App startup] --> B{Feature present?} B -->|Yes| C[Use native implementation] B -->|No| D[Dynamically import polyfill] D --> E[Then start the app] C --> E
// 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.

graph LR A[Progressive Enhancement] --> B[Start: semantic HTML that works] B --> C[Add CSS presentation] C --> D[Layer on JS enhancements] E[Graceful Degradation] --> F[Start: full modern experience] F --> G[Detect gaps in old browsers] G --> H[Add fallbacks so it still works]

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 familyTest onWhy it's on the list
Chromium (V8)Latest Chrome + EdgeLargest share; the "happy path"
Gecko (SpiderMonkey)Latest FirefoxIndependent engine; catches Chrome-only assumptions
WebKit (JavaScriptCore)Safari + any iOS browserThe 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.userAgent to 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

🚀 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.