Asynchronous and progressive CSS loading, the right way
CSS files are render blocking resources, which means that before a web page can be rendered, they have to be loaded and processed. While this is a great thing to ensure your pages look and feel as is intended, it can lead to slower page load times as your CSS grows, especially if you're loading CSS files from third-party domains.
So the browser makes this easy, right?
With JavaScript, we have lots of different options to asynchronously load our scripts, and prevent them impacting the critical rendering path. We're spoiled for choices, from simply putting them at the end of the document, to the async
and defer
attributes, to lazy-loading modules via import()
only as we need them, and more.
So it should be as easy as something like this then?
<link rel="stylesheet" src="/styles.css" async>
Unfortunately not. 😭
There's been lots of discussion about this over the years, with one of the most recent being this GitHub issue in whatwg/html
, but no consensus appears to have ever been reached. Some folks aren't sure this is something they even want anymore, with the more modern paradigm being to inline your critical CSS, and then lazy-load everything else via JavaScript anyway.
So you can do this with JavaScript?
Yes of course, but that's not really inline with the spirit of progressive enhancement. Pretty much every JS framework offers a way to lazy-load components that deliver their own CSS, which results in CSS-in-JS things, but when server-side rendering pages (so to support non-JavaScript users - remember, all of your users are non-JavaScript users while they're downloading your JS), or embedding third-party stylesheets, there isn't really a good solution.
There have been a few "solutions" to this problem over the years though, including the great loadCSS
project, but by far the the most common and modern solution is a media
+ onload
hack.
The media
+ onload
hack
A common solution online is to use the media
attribute, set it to print
, and then onload
switch it back to all
. You can also pair this with a preload
for a higher-priority fetch if needed. This looks something like this:
<link rel="stylesheet" href="/styles.css" media="print" onload="this.media='all'">
This does work, but it's absolutely a hack. It tells the browser that this stylesheet is only going to be used for print-based media, which prevents it from render-blocking and fetches with a lower priority, but then sets it back to be used for all
after it finishes loading. It also relies on JavaScript, and won't work with any kind of strict Content Security Policy that disallows unsafe-inline
script. 😅
The CSP issue can be worked around by doing something like setting a data
attribute on scripts you want to async load, and then handling the onload
logic in some other JavaScript, but it's reliant on JavaScript once again. 😢
A better solution
I figured there had to be a better solution, and after a bit of research, found a great article from Demian on the web.dev blog, Defer non-critical CSS. This post goes into a lot of detail and benchmark results for why you may want to do this, so I'd definitely recommend reading their post. This pointed me very much in the right direction for what I wanted to do:
- Asynchronously lazy-load non-critical CSS stylesheets on a website
- Provide a fallback for non-JavaScript users
- And do all of this while maintaining a strict CSP (no inline scripts)
After a lot of testing and iteration, my code ended up looking like this:
<link href="/styles.css" rel="preload" as="style" fetchpriority="low" crossorigin>
<!-- more stylesheeets to preload -->
<noscript data-css-lazyload>
<link href="/styles.css" rel="stylesheet" crossorigin fetchpriority="low">
<!-- more stylesheets, same as above preloads -->
</noscript>
const decodeHTML = function(html){
const textarea = document.createElement('textarea');
textarea.innerHTML = html;
return textarea.value;
};
const getItemsFromContainerText = function(container, selector){
const parser = new DOMParser();
const parsedHtml = parser.parseFromString(decodeHTML(container.textContent), 'text/html');
return parsedHtml.querySelectorAll(selector);
};
function loadCss(){
const cssContainers = document.querySelectorAll('noscript[data-css-lazyload]');
if(!cssContainers){
return;
}
const styleSheets = document.createDocumentFragment();
for(const cssContainer of cssContainers){
const sheets = getItemsFromContainerText(cssContainer, 'link[rel="stylesheet"]');
styleSheets.append(...sheets);
cssContainer.remove();
}
document.head.append(styleSheets);
}
loadCss();
This definitely isn't a standard way of loading CSS, but works like this:
- The
link rel=preload
elements in ourhead
request the stylesheets asynchronously. - The
link rel=stylesheet
elements in ournoscript
tag load the stylesheets in a more traditional way, when JavaScript is disabled - And then when we are in a JavaScript-enabled environment (where
noscript
is ignored), our JavaScript code parses thenoscript
stylesheet declarations and moves them into the standardhead
.
Let's break down the JavaScript a little bit:
- The
loadCss
function is doing most of the work here. First, it finds allnoscript
elements with ourdata-css-lazyload
attribute. - Then, it creates an empty document fragment which we'll be using in a moment to append our real stylesheets to.
- Looping through each
noscript
tag we found earlier, we find and parse any stylesheets, and append them to the document fragment we created. - And finally, append this fragment to the
head
. At this point, the stylesheets will probably have already been downloaded thanks to ourpreload
tags (just not render blocking), but if not, they will now be downloaded and executed as expected.
It's that last step where things get interesting though. My initial solution was to simply read the innerHTML
of the noscript
tag, and append that to the document. This worked flawlessly in Chrome and Firefox, but Safari seems to behave differently, and would return an escaped variant of this resulting in invalid escaped HTML being appended to the document instead, and the stylesheets not loading. I found some previous discussion about this on StackOverflow, and another GitHub project, which pointed me in the right direction for a solution.
My solution for this problem was taken from Nestor's issue as linked above. They ingeniously discovered that by using a textarea
and DOMParser
together, combined with textContent
rather than innerHTML
, you could get real, valid HTML in all browsers, and then use this to append to your DOM as expected. 🎉
So should I just lazy-load all of my CSS?
Probably not. Lazy-loading all of your CSS is likely going to cause layout shift, flash of unstyled content, and other detrimental UX results. However, when paired with a critical CSS strategy, and stylesheets that you know aren't going to be used above the fold, a solution like this can result in significant performance wins, especially with First Contentful Paint.
As always, benchmark and test for your own sites! This strategy from my experience can bring the biggest wins when you have either a lot of stylesheets for different elements on your site, or with third-party stylesheets for widgets, etc.
If you've found any other great ways to lazy-load your CSS, or just have any CSS performance tips and tricks in general, let me know in the comments below or on Twitter! 😃