Skip to main content
Saved
Guide
Difficulty Beginner

Building with Progressive Enhancement

Guide to building web applications that keep working for everyone, whatever their browser supports.

Den Odell
By Den Odell Added
In this article
  1. Start with Working HTML
  2. Detect Features Before Using Them
  3. Layer Enhancements Progressively
  4. Layer 1: Core HTML
  5. Layer 2: Basic Interactivity
  6. Layer 3: Rich Features
  7. Handle Navigation Gracefully
  8. Design for Varied Capabilities
  9. Test the Baseline
  10. Balance Enhancement with Simplicity
  11. Make Enhancement Routine

Building with Progressive Enhancement

I think of JavaScript frameworks as enhancement layers rather than foundations. Progressive enhancement means starting with HTML that works on its own, then adding richer functionality on top. This approach has little to do with ancient browsers. It keeps applications working when something goes wrong, and in my experience, something always goes wrong eventually.

Start with Working HTML

The baseline experience should work without JavaScript because, sooner or later, JavaScript will fail to load or run. Over the years, I’ve seen that happen in many different ways.

  • Forms submit via POST: With standard form actions, users can still complete their tasks when a CDN goes down and JavaScript stops loading. Many teams only see how much this helps after their first major outage.
  • Links navigate normally: Regular anchor tags work on every browser. Use Navigation Enhancement to upgrade them with client-side routing later, but navigation shouldn’t depend on it.
  • Content renders server-side: Deliver real HTML with Content Loading, not a blank page with a loading spinner while JavaScript downloads over a slow connection.

Plenty can go wrong: corporate firewalls block scripts, CDNs fail, users browse on low-powered devices, and ad blockers interfere with unexpected resources. With working HTML underneath, your application degrades gracefully in those cases instead of failing entirely.

Detect Features Before Using Them

Assuming every browser supports a given API ships bugs to production. Checking for the feature first, then enhancing, avoids them.

  • Test API availability: Use Feature Detection before using IntersectionObserver, ResizeObserver, or the History API. If the feature doesn’t exist, the fallback runs instead.
  • Provide sensible fallbacks: Without intersection observers, load images immediately. When users prefer reduced motion, skip parallax scrolling. The application still works, just with fewer visual effects.
  • Avoid user agent sniffing: Parsing user agent strings to guess browser capabilities is unreliable, breaks frequently, and reveals nothing about what the browser can actually do. Test for the feature itself.

Layer Enhancements Progressively

I find it helpful to think of progressive enhancement as a series of layers, where each builds on the one below. If a higher layer fails to load, the layers beneath still function.

Layer 1: Core HTML

Server-rendered content with working forms and navigation.

<!-- Works without any JavaScript -->
<form method="POST" action="/search">
  <input type="search" name="query" required>
  <button type="submit">Search</button>
</form>

Layer 2: Basic Interactivity

Enhance forms and navigation when JavaScript loads.

// Enhance form with client-side submission
const form = document.querySelector('form');
if (form && 'fetch' in window) {
  form.addEventListener('submit', async (e) => {
    e.preventDefault();
    const formData = new FormData(form);
    try {
      // Fetch results without full page reload
      const response = await fetch('/search', {
        method: 'POST',
        headers: { Accept: 'application/json' },
        body: formData
      });
      if (!response.ok) throw new Error(response.statusText);
      updateResults(await response.json());
    } catch {
      form.submit(); // Fall back to the normal form submission
    }
  });
}

Layer 3: Rich Features

Add advanced features only when supported.

// Add live search suggestions, cancelling stale requests with AbortController
const input = document.querySelector('input[type="search"]');
if (input && 'AbortController' in window) {
  let controller;
  input.addEventListener('input', debounce(async () => {
    controller?.abort();
    controller = new AbortController();
    try {
      const response = await fetch(`/suggest?q=${encodeURIComponent(input.value)}`, {
        signal: controller.signal
      });
      showSuggestions(await response.json());
    } catch {
      // Aborted or failed: the search form still works without suggestions
    }
  }, 300));
}

Handle Navigation Gracefully

Single-page applications often break normal browser navigation, but a few habits avoid that.

  • Links work by default: Start with real <a> tags. If JavaScript never loads, navigation still works.
  • Intercept strategically: Use Navigation Enhancement to upgrade internal links with client-side routing, but respect modifier keys (Cmd+click) and external links. Leave everything else to the browser.
  • Manage history properly: Update the URL with pushState and handle popstate events, or use the Navigation API, which has worked in every major browser since early 2026. Users expect the back button to work the way it always has.
  • Handle failures: If client-side navigation fails, catch the error and fall back to a full page reload. A slow navigation is better than a broken one.

Design for Varied Capabilities

Users browse in many different ways. Some navigate by keyboard, some find animations uncomfortable or disorienting, and some rely on screen readers.

  • Respect reduced motion: Check prefers-reduced-motion and reduce or eliminate animations accordingly. What looks appealing to some users can cause real discomfort for others.
  • Support keyboard navigation: Make every interactive element work with Tab, Enter, and arrow keys. Use Focus Management so keyboard users can navigate your interface reliably.
  • Announce dynamic changes: When content updates, inform screen reader users through ARIA live regions, since they can’t see visual transitions.

Test the Baseline

Untested progressive enhancement gives you false confidence, because you don’t know whether the fallbacks work.

  • Disable JavaScript: Turn it off in your browser settings and try to use your application. If critical features break completely, the application depends on JavaScript and isn’t progressively enhanced.
  • Throttle network: Slow connections are normal for millions of users every day, so check that your application loads content in a reasonable time rather than stalling indefinitely.
  • Test older browsers: Use a browser without your preferred modern APIs. Confirm that feature detection works correctly and that fallbacks engage as expected.

Balance Enhancement with Simplicity

Progressive enhancement doesn’t mean supporting every browser ever made.

  • Choose your baseline thoughtfully: Extremely old browsers don’t need support, but HTML should work without JavaScript on the browsers people use today, which is a reasonable standard.
  • Document your assumptions: Write down your baseline requirements. Perhaps you require CSS Grid but not JavaScript. Perhaps your admin dashboard requires JavaScript but your marketing pages don’t. Make these decisions explicit.
  • Enhance strategically: Not everything needs to work without JavaScript. A real-time collaborative editor can’t work without it, but a blog post should render without it, so it depends on what you’re building.

Make Enhancement Routine

Progressive enhancement works best when it becomes standard practice rather than special effort.

  • Start with HTML: Write the markup and server routes first. Get them working, then add JavaScript on top. This approach forces you to consider the baseline from the beginning rather than retrofitting it later.
  • Default to standard elements: Use native <button>, <input>, and <a> tags before reaching for custom components. They work everywhere without extra effort, and they often handle problems that custom implementations struggle with.
  • Test as you build: Check that each enhancement degrades gracefully before adding the next one. Catching issues early is far easier than debugging a broken enhancement chain later.

Users care that the checkout button works when they click it, and progressive enhancement keeps it working when something else on the page fails.

Newsletter

A Monthly Email
from Den Odell

Behind-the-scenes thinking on frontend patterns, site updates, and more

No spam. Unsubscribe anytime.