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 our head request the stylesheets asynchronously.
  • The link rel=stylesheet elements in our noscript 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 the noscript stylesheet declarations and moves them into the standard head.

Let's break down the JavaScript a little bit:

  • The loadCss function is doing most of the work here. First, it finds all noscript elements with our data-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 our preload 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! šŸ˜ƒ

You've successfully subscribed to James Ross
Great! Next, complete checkout for full access to James Ross
Welcome back! You've successfully signed in.
Success! Your account is fully activated, you now have access to all content.