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 isn’t about supporting ancient browsers. It’s about building applications that remain functional when things go wrong, and in my experience, things always go wrong eventually.
Start with Working HTML
The baseline experience should work without JavaScript because JavaScript will fail. Over the years, I’ve seen it happen in countless ways, and the question isn’t if but when.
- Forms submit via POST: Use standard form actions. When a CDN goes down and JavaScript stops loading, users can still complete their tasks. This matters more than most teams realise until they experience 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.
This baseline protects you when circumstances turn difficult. Corporate firewalls block scripts, CDNs fail, users browse on constrained devices, and ad blockers interfere with unexpected resources. A working HTML foundation means your application degrades gracefully instead of failing entirely.
Detect Features Before Using Them
Assuming every browser supports a given API is a reliable way to ship bugs to production. The better approach is to check first and enhance second.
- Test API availability: Use Feature Detection before using
IntersectionObserver,ResizeObserver, or theHistory 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 flourishes.
- 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);
// Fetch results without full page reload
const response = await fetch('/search', {
method: 'POST',
body: formData
});
updateResults(await response.json());
});
}
Layer 3: Rich Features
Add advanced features only when supported.
// Add real-time search suggestions if supported
if ('IntersectionObserver' in window) {
const input = document.querySelector('input[type="search"]');
input.addEventListener('input', debounce(async () => {
const suggestions = await fetch(`/suggest?q=${input.value}`);
showSuggestions(await suggestions.json());
}, 300));
}
Handle Navigation Gracefully
Single-page applications frequently mishandle navigation. With some care, yours doesn’t have to.
- 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. Let browsers handle what they handle well.
- Manage history properly: Update the URL with
pushStateand handlepopstateevents. 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-motionand reduce or eliminate animations accordingly. What looks appealing to some users can cause genuine 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. They cannot perceive visual transitions.
Test the Baseline
Progressive enhancement that isn’t tested provides false confidence. Take time to verify that fallbacks actually work.
- Disable JavaScript: Turn it off in your browser settings and try to use your application. If critical features break completely, you’re not progressively enhancing; you’re building a JavaScript-dependent application.
- Throttle network: Slow connections aren’t edge cases. They’re daily reality for millions of users. Verify 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. A pragmatic approach serves teams better.
- Choose your baseline thoughtfully: Supporting extremely old browsers isn’t necessary. But HTML should work without JavaScript on browsers people actually use today. That’s 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 genuinely requires it. A blog post should render without it. Apply judgment based 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 additional effort. Native elements often solve problems that custom implementations struggle with. - Test as you build: Verify each enhancement degrades gracefully before adding the next one. Catching issues early is far easier than debugging a broken enhancement chain later.
Remember: Users care that the checkout button works when they click it. Progressive enhancement means shipping applications that function reliably, regardless of circumstances.