Progressively Enhance for a More Resilient Web
There has been a lot of talk on the socials lately about progressive enhancement. Some good, some bad, and while the bad is often misled I get it.
Educational content on this topic usually focuses on making things work without JavaScript. That is cool but I agree with the naysayers that JavaScript is a fundamental part of the web these days, so why should we invest time in making things work without it?
Well, it isn't only about building things that "support 1% of users without JavaScript" and I'd like to try explain why. But first, let's talk about that 1%.
The 1% without JavaScript
I'd like to start by clarifying that it does not mean 1% of people using the internet have JavaScript disabled. It means 1% of the visits to your website will end up with a JavaScript-free experience, often through no fault of their own.
That number might not sound too frightening but 1% of a big number is a lot. For example, 1% for BuzzFeed was around 13 million requests per month back in 2018.
Our monitoring tells us that around 1% of requests for JavaScript on BuzzFeed timeout. That's around 13 million requests per month.
That figure only represents their JavaScript requests that timeout too. Now consider that any JavaScript written in your codebase could error.
Perhaps you've used a new browser API that isn't supported in the lower end of your supported browser matrix you forgot to test. Or, some dynamic code attempts to access a property from an object at an array index that doesn't exist. Or, your user has a browser extension disabling third-party trackers and your logic assumes Sentry is always available.
TypeScript wouldn't catch these things but thankfully, any user interaction that errors will make their browser fallback to the JavaScript-free experience so they can continue to use your website. Oh but wait, they can't 😢 ... I wonder what BuzzFeed's JavaScript error count is 💠... anyway, moving on.
We could litter our apps with ErrorBoundary
components that say "oops we messed up", or we could spend an insignificant amount of time on a fallback experience because it isn't about disabling JavaScript but more about your website's availability.
Stuart Langridge has visualised why availability is so important which includes an interesting point—if a user cannot use your website on the airplane Wi-Fi because your JavaScript keeps timing out or erroring, they might assume your website doesn't work and never come back.
These are all interesting points but what if 1% to you is only 100 visits. Is it something worth caring about? Yes.
Why it matters to the 99%
I have been doing this web thing professionally for 21 years. Back when I started, progressive enhancement was such a must-have that many companies made it a requirement on their job descriptions. Admittedly, it was mostly because browsers were super old and there was a much larger group of people without JavaScript. Because of this though, I learnt it. I really learnt it.
To learn progressive enhancement I had to learn semantic markup, I had to learn where event.preventDefault
should go in my JavaScript handlers to ensure a fallback experience when JavaScript errored, I had to learn how forms work and many more browser fundamentals.
Learning all of this meant that the 99% who had the enhanced experience also benefited. They could CMD + Click
links to open in a new tab, they could use the tab key on their keyboard to navigate around pages, they didn't have to download unnecessary bytes of JavaScript to maintain form state, and TTI was fast because they didn't need to wait for the JS bundle to search our products.
It ensured a more accessible experience for all. But then we stopped caring so much about progressive enhancement.
Progressive enhancement dies
Fast forward many moons and progressive enhancement fell by the wayside. The web industry moved on to JavaScript frameworks and in an effort to keep up I also had to move on.
No one seemed to care about progressive enhancement anymore and although unfortunate, I needed to put food on the table. Thankfully I had still gained the knowledge of building accessible/semantic pages which I could take with me, but then I started noticing how component driven architecture encourages composition without consideration of the markup being rendered.
The web slowly became an unusable mess for people with JavaScript. Forms that won't submit till a mammoth JS bundle finishes loading, divs for links, no keyboard support etc. and it wasn't an educational issue. I learnt all this stuff, I know how it works, but I still made these mistakes.
More recently, I used JavaScript to attach an src
attribute to an image which meant that the 99% had to wait the extra time it takes for the JavaScript to load before their browser even began to download the image. The 1% would never see an image! This is something HTML has supported since the dawn of time and I still managed to worsen the experience for the 99%.
So I realised, progressive enhancement isn't only about supporting that 1%. It's about testing your app without JavaScript to ensure 100% of your users have a more performant, usable, available, and resilient experience.
Progressive enhancement reborn
When Remix was in paid beta, I jumped on the opportunity to try it because, at last, someone was focusing on bringing progressive enhancement to the JavaScript framework world. I pulled code from previous projects into a new Remix project, commented out the <Scripts />
component in root.tsx
to prevent scripts from loading, and spun it up.
I quickly discovered areas of my code that could easily be improved (src
issue for example). Was I going to spend hours trying to build a JavaScript-free UI for a Combobox though? How is that even possible?! We need JavaScript to provide all the aria attributes and roles for accessibility right? Well, this is where many seem to misunderstand progressive enhancement.
Progressive enhancement does not mean you need to provide the exact same UI without JavaScript. The enhanced experience should be better and it should do more, otherwise the enhanced experience is not needed at all. It enhances a degraded experience that also allows the user to accomplish their goal. For example, entering a postal code manually into a text box might be the degraded experience, and the progressively enhanced experience would prefill the text box based on Geolocation data.
You can see with this example that both experiences require the text box and there is no extra effort that went into creating the degraded experience. It is the baseline that allowed us to progressively apply the enhanced experience.
If you're spending hours building out a completely separate UI with logic that is no use to the enhanced experience, then you are probably doing it wrong. Build a baseline experience that can be reused when JavaScript is available and it will not be a huge effort.
Let's revisit combobox.
Progressively enhanced combobox
For a combobox, a degraded experience could just render an input which users manually type into without any suggestions. That's it. The same input would then be enhanced to provide the popover suggestions when JavaScript is available.
Alternatively, if selecting from the results is important, we can provide those too:
return (
<div className="combobox">
<form method="get" action="/api/some-data">
<input type="search" name="search" />
<button className="combobox__submit">Search</button>
</form>
<div className="combobox__results">
<ul>{/* ... */}</ul>
</div>
</div>
);
When JavaScript isn't available the user can press a submit button which would submit to the form action and render the results below.
When JavaScript is available it would hide the submit button, add the aria roles, attributes, and keyboard functionality for accessibility, and make a fetch
request to the form action with an event.preventDefault()
in the onSubmit
handler. The results would be listed in the same element below using CSS to absolutely position them.
There isn't significant effort here to provide the degraded experience. In fact, most of the effort is spent on the enhanced experience where all non-js UI and logic is reused and extended. The only overhead in the non-js version is the button
which we've hidden.
Layout shift
Hiding elements when JavaScript becomes available is one of the more frustrating parts of progressive enhancement because it can cause a layout shift when JavaScript loads. In the combobox example, we can style it to look like the arrow button in the WAI-ARIA example instead and then we wouldn't need to hide it at all. It would be reused across both experiences and avoid a layout shift.
What about tabs? We used to handle them by rendering the tab buttons as heading tags with content below. When JavaScript was available we would collapse everything into the tab interface but that is one huge layout shift. Instead, we can change the tab buttons to anchors that link to panel IDs, sprinkle a little CSS and we'll have tabs that work without JS. All with the same baseline the enhanced experience needs, and no layout shift.
I recommend trying to think of ways to avoid layout shift like this by providing a baseline that can be reused in both experiences. It is important to consider the perceived affordance when trying to maintain a similar experience though. I purposefully didn't copy the radio button examples for tab components because it would create a more extreme perceived affordance and functionality mismatch.
My alternative also isn't perfect though. As soon as JS kicks in and the tabs are styled to look like tab buttons with tab roles, keyboard and screen reader users will understand that they can use their arrow keys which might not be the case if the tabs have JavaScript errors.
It isn't always possible to maintain a functionality and affordance match, especially when considering the JS error case, but personally I think providing something that might mismatch slightly is better than no fallback at all.
Avoiding layout shift with .has-js
If you are still struggling to create a reusable baseline without layout shift, there is a handy .has-js
trick we can use by adding the following script to the <head>
:
<script>
document.body.classList.add('has-js');
</script>
By inserting this early in the document, we could have hidden the combobox button without layout shift using the CSS below. This works because the browser will stop parsing the document when it reaches the script and handover to the JavaScript interpreter. It would add the class before parsing the rest of the document.
.has-js .combobox__submit {
display: none;
}
When the rest of the document is parsed, the CSS will prevent the button from ever appearing. This introduces a new problem though—If the combobox JavaScript errors, the button cannot reappear so try to prioritise incorporating the fallback version into the enhanced version.
If all else fails, it's probably okay to accept that a degraded experience for that particular part of your interface is not going to cut it. Again, aiming for a progressively enhanced experience is better than not considering it at all if we want a more accessible, usable, and resilient web.
Conclusion
There is more to progressive enhancement than supporting people who disable JavaScript. I hope this will encourage you to test your projects without JavaScript. Maybe you will find some obvious quick wins where things can be improved for everybody.