Skip to main content
Saved
Guide implementation Beginner 6 min read

Building with Progressive Enhancement

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

I think of JavaScript frameworks as enhancement layers rather than foundations. With progressive enhancement, you start with HTML that works without any JavaScript, and then add richer functionality on top. It has much less to do with ancient browsers than with keeping your application working when something goes wrong. In my experience, something always goes wrong eventually.

Start with Working HTML

Your 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 realize how much this helps after their first major outage.
  • Links navigate normally: Regular anchor tags work in every browser. You can use Navigation Enhancement to upgrade them with client-side routing later, but navigation shouldn’t depend on it.
  • Content renders server-side: You should deliver real HTML with Content Loading, instead of a blank page and a loading spinner while JavaScript downloads over a slow connection.

Plenty of things can go wrong. A corporate firewall might block scripts, a CDN might fail, a user might be on a low-powered device, or an ad blocker might interfere with an unexpected resource. If there’s working HTML underneath, your application can degrade gracefully in those cases instead of failing completely.

Detect Features Before Using Them

If you assume every browser supports a given API, you’ll end up shipping bugs to production. You can avoid them by checking for the feature first, and only then adding the enhancement.

  • Test API availability: You can use Feature Detection to check for IntersectionObserver, ResizeObserver, or the History API before your code relies on them. If the feature doesn’t exist, the fallback runs instead.
  • Provide sensible fallbacks: If the browser doesn’t support intersection observers, the page can load the images straight away, and if the user prefers reduced motion, it can skip the parallax scrolling. The application still works, but with fewer visual effects.
  • Avoid user agent sniffing: Guessing what a browser can do from its user agent string is unreliable, because the string doesn’t tell you which features the browser actually supports. It’s better to test for the feature itself.

Layer Enhancements Progressively

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

Layer 1: Core HTML

The first layer is server-rendered content, with forms and navigation that already work.

<!-- 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

Once JavaScript loads, it can enhance the forms and navigation.

// 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

More advanced features only get added when the browser supports them.

// 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 you can avoid that with a few habits.

  • Links work by default: Your links should start as real <a> tags, so that if JavaScript never loads, navigation still works.
  • Intercept strategically: You can use Navigation Enhancement to upgrade internal links with client-side routing, but you should let the browser handle external links and clicks with modifier keys, such as Cmd+click. Anything you don’t need to intercept is best left to the browser too.
  • Manage history properly: You can update the URL with pushState and handle popstate events, or use the Navigation API, which has been supported 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, you can catch the error and fall back to a full page reload. A full reload is slower, but the user still gets to the page they wanted.

Design for Varied Capabilities

People use the web in many different ways. Some navigate by keyboard, some find animations uncomfortable or disorienting, and some rely on screen readers.

  • Respect reduced motion: You can check prefers-reduced-motion and reduce or remove animations for users who’ve enabled it. An animation that some users like can be really uncomfortable for others.
  • Support keyboard navigation: Every interactive element should work with Tab, Enter, and arrow keys, and Focus Management helps keyboard users navigate your interface reliably.
  • Announce dynamic changes: When content updates, you can let screen reader users know through ARIA live regions, because they can’t see the visual transitions.

Test the Baseline

If you haven’t tested your fallbacks, you don’t know whether they work, and it’s easy to assume they do.

  • Disable JavaScript: If you disable JavaScript in your browser settings and critical features break completely, your application depends on JavaScript and isn’t progressively enhanced.
  • Throttle network: Millions of people use slow connections every day, so it’s worth checking that your application loads content in a reasonable time instead of hanging indefinitely.
  • Test older browsers: In a browser that doesn’t support the modern APIs you’re using, you can check that feature detection works and that the fallbacks run.

Balance Enhancement with Simplicity

Progressive enhancement doesn’t require you to support every browser ever made.

  • Choose your baseline thoughtfully: You don’t need to support extremely old browsers, but your HTML should work without JavaScript in the browsers people use today.
  • Document your assumptions: It’s worth documenting your baseline requirements. You might require CSS Grid but not JavaScript, or your admin dashboard might require JavaScript while your marketing pages don’t.
  • Enhance strategically: Not everything needs to work without JavaScript. A real-time collaborative editor can’t work without it, but a blog post should still render, so it depends on what you’re building.

Make Enhancement Routine

Progressive enhancement works best when your team does it as a normal part of building, instead of treating it as a special effort.

  • Start with HTML: You can write the markup and server routes first, get them working, and then add JavaScript on top. That way, you’re thinking about the baseline from the beginning, instead of trying to add it in later.
  • Default to standard elements: Native <button>, <input>, and <a> tags are worth using before you build custom components. They work everywhere without any extra effort, and they often handle problems that custom components get wrong.
  • Test as you build: It’s worth checking that each enhancement degrades gracefully before you add the next one. It’s much easier to catch problems at that point than to debug a whole chain of broken enhancements later.

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

Next Guide · 4 min read

Choosing a CSS Strategy

How to choose between BEM, CSS-in-JS, utility-first CSS, and CSS Modules for your team and your project.

Read the next guide →

Newsletter

A monthly email
from Den Odell.

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

No spam. Unsubscribe anytime.