Skip to content

Instantly share code, notes, and snippets.

@scottjehl
Last active August 12, 2023 16:57
Show Gist options
  • Save scottjehl/87176715419617ae6994 to your computer and use it in GitHub Desktop.
Save scottjehl/87176715419617ae6994 to your computer and use it in GitHub Desktop.
Comparing two ways to load non-critical CSS

I wanted to figure out the fastest way to load non-critical CSS so that the impact on initial page drawing is minimal.

TL;DR: Here's the solution I ended up with: https://github.com/filamentgroup/loadCSS/


For async JavaScript file requests, we have the async attribute to make this easy, but CSS file requests have no similar standard mechanism (at least, none that will still apply the CSS after loading - here are some async CSS loading conditions that do apply when CSS is inapplicable to media: https://gist.github.com/igrigorik/2935269#file-notes-md ).

Seems there are a couple ways to load and apply a CSS file in a non-blocking manner:

  • A) Use an ordinary link element to reference the stylesheet, and place it at the end of the HTML document, after all the content. (This is what Google recommends here https://developers.google.com/speed/docs/insights/PrioritizeVisibleContent )
  • B) Fetch it asynchronously with some inline JavaScript from the head of the page, by appending a link element to page dynamically. (To ensure it's async, I'm setting the link's media to a non-applicable media query, then toggling it back after the request goes out).

I suspected B would initiate the request sooner, since A would be dependent on the size of the content in the document - parsing it in entireity before kicking off the request.

In this case, sooner would be ideal to reduce impact on our page load process, since there could be minor reflows that are triggered by the styles in the non-critical CSS file (ideally, the critical CSS would have all reflow-triggering styles in it, but that so far, that's been hard to pull off across the breakpoints of a responsive design, and given that the initial inlined CSS should be kept very small to fit into the first round trip from the server).

I made some test pages, and they do seem to confirm that B will load the CSS sooner (B requests the css file after around 60-70ms whereas A usually requests it around 130-200ms). It's consistently about twice as fast.

B comes with the additional benefits of qualifying the request based on whatever conditions we care to test too, which is nice.

@matijs
Copy link

matijs commented Jun 10, 2014

@scottjehl Are you trying to (pre)cache css for subsequent pages? I'm struggling a little to see other use cases.

@scottjehl
Copy link
Author

@adactio Great point. I agree and added a note up top. I guess it also depends on how truly non-critical that second stylesheet is too. The experience could be fine without it, right?
Another approach could be to inline the small-screen styles and then reference the larger breakpoint styles via a link with a media attribute representing its minimum breakpoint. That'd at least allow the second request to load without blocking in places where the media query doesn't apply, but the implication that large screens will have a faster connection and won't mind that blocking request as much seems... debatable I guess.

@matijs inlining some of the CSS will lose out on regular caching in favor of having fewer obstacles in the critical path to a fast page load. More on that here https://developers.google.com/speed/docs/insights/PrioritizeVisibleContent

@igrigorik
Copy link

@scottjehl some webpagetest runs would go a long way here :)

The "link at the bottom of the page" approach is not representative. I wouldn't use it as a concrete data point: the performance of that approach will vary a great deal based on size of the HTML document and browser-specific implementations (e.g. some browsers will yield an early paint if they consumed enough tokens / are blocked / are stuck waiting for a big HTML doc for a long time). I'd just avoid it entirely. If you're placing a <link> tag in your doc, put it at the top.

In the short term, script-injected + non-applicable MQ is probably the best approach. In the (hopefully not so distant) future, <link lazyload> should make this much simpler: https://dvcs.w3.org/hg/webperf/raw-file/tip/specs/ResourcePriorities/Overview.html#the-link-element

@scottjehl
Copy link
Author

Thanks, Ilya!

@simevidas
Copy link

@scottjehl Just a heads-up. I used to put a link rel="stylesheet" at the very bottom of my page but the browser prioritized it and it was requested before all the JS/fonts/images (which were all referenced higher in the page). I can post a network waterfall diagram to prove this tomorrow.

@scottjehl
Copy link
Author

Sounds about right, @simevidas. I think that's another good reason to avoid that approach for this use case.

@phamann
Copy link

phamann commented Jun 11, 2014

We've been using this approach for nearly a year now in production on the Guardian responsive site, and have learnt some of the issues mentioned in this thread along the way. Our current approach is as follows:

In the head of the document:

  • Inline critical CSS (placed as soon as possible within the head, even before most <meta>)
  • Sync load all global styles with a <link> for older browsers
  • Load global styles from localStorage if available and inject into <style> element

https://github.com/guardian/frontend/blob/master/common/app/views/fragments/commonCssSetup.scala.html

In the foot of the document:

  • Include no script for older browsers
  • Load async
    -- Inline into <style> in the head
    -- Cache into localStorage for next page load

https://github.com/guardian/frontend/blob/master/common/app/views/fragments/loadCss.scala.html

The reason we use localStorage is to be more resilient against single point of failures. I.e. we now have the entire styles of the page within the initial html payload (a single HTTP request). You could argue that the same could be said if the global styles are in the HTTP cache, but this is rarely the case on mobile. Hopefully the ServiceWorker spec will standardise such hacking and allow us to design for offline first.

With regards to the "what are critical styles" question, ours are a mixture of breakpoints to avoid FOUC on larger devices, but largely covering the header, navigation, base layout and content typography. Due to the file size restrictions (we enforce max 14kb gzipped), we've found that we can't have a single head file that accommodates every section/content type across the site. So have resorted to maintaining ~3-4 head files to fine tune the experience. This does result in duplicate styles in your global stylesheet, but the trade-off is worth it, you just have to be careful of the cascade.

I hoping that a combination of HTTP/2 and the Resource priorities API are going to make these techniques anti-patterns in the future 😉. Thanks for opening this thread @scottjehl it's very interesting to hear others experiments.

@decadecity
Copy link

@simevidas That's my experience too, I tried putting a link at the end of the page but found it made no difference in the RUM data for time to parse the document and DOMContentLoded, even if the link was injected by JS. Making it genuinely async brought both those times down by ~0.5s.

@adactio I share your concerns about making CSS dependent on JS but, providing the page is usable with the inline CSS, when JS isn't available the extra styles are just another progressive enhancement.

In terms of splitting what CSS is inlined and what is lazy loaded my experience of this comes down to two main factors:

  1. Is the page usable with the inline styles? As stated above, the page has to at least function with JS disabled, even if it's not going to win any design awards in this state.
  2. How much of a FOUC is there when the enhanced styles load? On a slow network there can be a noticeable delay before the enhanced styles arrive and, if the page was already usable, then large jumps in content layout are off putting.

The browser's default behaviour of blocking as CSS loads is very safe and saves us having to think about some complicated things, by doing this we are taking responsibility for the consequences and have a new set of problems to solve.

@scottjehl
Copy link
Author

@phamann lots of interesting tips there, thanks!
Had you also found in your testing that the insertBefore + non-applicable media toggle is best for ensuring a non-blocking request request? With the media toggle removed I've seen the request end up blocking render, so I wondered if you're also doing that at the Guardian (I haven't looked at that part of your code yet). I need to go back and add that to our projects now!

@scottjehl
Copy link
Author

Update: loadCSS has its own home now! https://github.com/filamentgroup/loadCSS/

@phamann
Copy link

phamann commented Jun 11, 2014

@scottjehl no we had noticed that, very nice trick! I'll add that to our implementation now. Thanks.

@Boldewyn
Copy link

Would it be better to place the <link media="only x"> elements directly in the head and let JS only change the @media attribute? The JS could be condensed to

setTimeout(function() {
  Array.prototype.slice.call(document.head.querySelectorAll('link[media="only x"]'))
    .forEach(function(link) {
      link.setAttribute('media', 'screen');
    });
});

(Newer browsers assumed.)

@gui-poa
Copy link

gui-poa commented Jun 11, 2014

Scott, in which browsers this technique (loadCSS function) works?

@igrigorik
Copy link

The reason we use localStorage is to be more resilient against single point of failures. I.e. we now have the entire styles of the page within the initial html payload (a single HTTP request). You could argue that the same could be said if the global styles are in the HTTP cache, but this is rarely the case on mobile.

@phamann have you measured this? I'd love to see some real data proving this .... and then figure out why, because this certainly shouldn't be the case.

@shshaw
Copy link

shshaw commented Jun 11, 2014

Interesting idea. Since you recommend including the non-essential CSS in a <noscript> wrapper, why not use that <noscript> element as the CSS file list that loadCSS then pulls from?

Here's a very rough, very quick mockup in jQuery:

<noscript class="async-css">
  <link rel="stylesheet" href="http://scottjehl.com/test/loadingcss/foo.css" media="all">
</noscript>
<script>
  $("noscript.async-css").each(function(){
    var $this = $(this),
        $links = $($this.text()).filter("link")
          .attr("media","only foo")
          .appendTo($this.parent());

    setTimeout( function(){ $links.attr("media","all"); },1 );
  });
</script>

It would take a little adjusting to be made in pure Javascript, but I like having the fallback built-in and reducing the repetition of the CSS URLs.

@shshaw
Copy link

shshaw commented Jun 11, 2014

Created a fork with a pure Javascript version of the functionality I describe above.

<noscript class="loadCSS"><link rel="stylesheet" href="myStyles.css" media="all"></noscript>
<script>
loadCSS();
</script>

Here's a JSBin example as well: http://jsbin.com/vazon/3/edit

Only tested in Chrome, but it should run fine cross-browser.

@matt-bailey
Copy link

For what it's worth I was working on something similar the other day. I modified the script on Google Developers so that I could pass in a string (in my case the classes applied to the body tag), and then not only load multiple CSS files asynchronously, but also filter them by looking for a match in that string - basically you can conditionally load CSS files based on 'pagetype', meaning you can cut back even more on loading styles that aren't required for the particular page you're on.

Here's my script if you're interested: https://gist.github.com/matt-bailey/602b40c77a5d3381ff26

I like what you did with 'media' in order to ensure you can fetch without blocking the render. I'm going to incorporate that in my version if that's ok?

@woeldiche
Copy link

@matt-bailey - wouldn't that require or assume that the site is styled page by page - something that most devs now a days would try hard to avoid?

@shshaw
Copy link

shshaw commented Jun 12, 2014

@woeldiche If only certain pages have a module with particularly heavy styles, why not only include those styles on relevant pages? I try to avoid that in general, but if you're using a CMS and plugins, you may not be the author of the styles. Keeping overall page weight down by only including that when necessary seems like a win to me.

@woeldiche
Copy link

@shshaw True. Loading stylesheets based on modules would make good sense to me. A lot of projects are already structured that way as sass/less partials.

My concern would be the number of request unless you also utilize a module bundler to combine a number of css modules into a low number of requests. That again would of course be a trade-off between caching and http-request.

It should be possible to combine the above technique with a bundler like http://webpack.github.io/ for css modules.

@pocketjoso
Copy link

This is great, I've been looking for a better approach to handle non critical css! Will put to use.

@phamann: Do you then classify all browsers that don't support localStorage as old? Otherwise they will have to reload all CSS inline on every page?

Regarding critical styles, I include everything that's above the fold on the current page. I built a tool that can automatically generate this CSS for you (you can use it in your build).

The idea of having more than two levels of importance for the CSS to load is interesting... Obviously CSS related to different pages goes on the non-important pile.. But say icon-fonts, dynamically inserted content, content just below the fold... could certainly make for a second-teir "critical" CSS.. But how would this be used? Injected into the DOM below the corresponding (arbitrary) fold? Not sure if it's worthwhile introducing this level of complexity.

@matt-bailey
Copy link

@woeldiche, @shshaw All our projects use a very modular, component based Sass structure, from which we are able to build a critical.css file (inlined in the head), a base.css file (global styles) and then, if required, the styles for particular page types.

For example, we do a lot of ecommerce sites - if we know that a specific component is only used on Product Pages then it goes in product-page.css.

Yes this adds one more http request, but the files are loaded asynchronously and once the browser has that file it's cached. Plus this also massively reduces redundancy - I tested one of our, non-optimised sites the other day and 85% of the styles in main.css weren't being used.

Of course these CSS loading techniques aren't the only facet involved in optimising the critical rendering path, but we've seen our optimised sites conversion rates shoot up after implementation - making for very happy clients! :)

@kukulich
Copy link

@scottjehl Does this really work? I have just tried last versions of Firefox and Chrome and both loaded my CSS synchronously and blocked rendering. I tested it with one very slowly loading CSS file so I can really see if it works.

@thienedits
Copy link

Here's a method I found using XHR. Only drawback is it injects the css styles into a style element.

https://gist.github.com/thienedits/d99aa3f841a1a156d47a

@Jakobud
Copy link

Jakobud commented Mar 11, 2015

@thienedits I don't think that's really a drawback. If something is sitting in a separate file or if it's injected into the document, your styling with behave the same way. Your browser will cache an injected stylesheet too. In fact, this is a common way of dealing with SVG files. You can keep your SVG cachable, and inject it's contents directly into your HTML during page load. This gives the advantage of caching but allows allows you to leverage every aspect of SVG's, such as dynamically changing SVG element styles, etc, which you cannot do when simply referencing an external SVG file.

@FibreFoX
Copy link

There is one problem with injected <style>: injecting CSS via inline-style-block could be blocked by CSP:
https://developer.mozilla.org/en-US/docs/Web/Security/CSP/CSP_policy_directives#style-src

Despite of all existing (script-)workarounds for this problem, we have to finde a solution for the real problem: page-blocking css-requests! That lazyload-attribute looks like the solution for web-devs, you can influence the browser-behaviour, just like async on scripts. It's like a mysterious chicken-egg-problem to me, because using "progressive enhancement" seems not to work here (1. html, 2. style, 3. script), every part could be blocked (doesn't always have to be disabled by the user itself, some adblockers or anti-script-addons of browsers can make this fail), or just not fully parsed.

For me its a really interesting topic, which definately should have more eyes on it.

@2kool2
Copy link

2kool2 commented Jul 31, 2015

I'm just curious, has anyone tried downloading the CSS as a script with type="text/css" and async?

<script id="styles" async type="text/css" src="style.css"></script>
<noscript><link rel="stylesheet" href="style.css"></noscript>

Surely that'd be non-blocking?
Then use JS to either rename the script tag to style, or copy its content across.
I tried something similar for overwriting critical-path-only CSS, which worked but was unused for other reasons.

@Garconis
Copy link

For the record, Demo A loads the CSS faster than Demo B. For me, at least.

Copy link

ghost commented May 26, 2016

head

<script>
      (function() {
      'use strict';
      var head = document.getElementsByTagName('head')[0];
      var bootnap = document.createElement('link');
      bootnap.rel = 'stylesheet';
      bootnap.href = './css/bootstrap-theme1.min.css';
      head.appendChild(bootnap);
      }());
</script>

Before </body>

<link href='https://fonts.googleapis.com/css?family=Roboto:400,700' rel='stylesheet' type='text/css'>

Result:

1

score1


head

<script>
// notice the difference between this one and the first one
      (function() {
          'use strict';
          var head = document.getElementsByTagName('script')[0];
          var bootnap = document.createElement('link');
          bootnap.rel = 'stylesheet';
          bootnap.href = './css/bootstrap-theme1.min.css';
          bootnap.media = 'only x';
          head.parentNode.insertBefore(bootnap, head);
          setTimeout(function() {
            bootnap.media = 'all';
          });
      }());
</script

Before </body>

<link href='https://fonts.googleapis.com/css?family=Roboto:400,700' rel='stylesheet' type='text/css'>

Result:

2

score2


In the head

<script>
      (function() {
          'use strict';
          var head = document.getElementsByTagName('script')[0];
          var bootnap = document.createElement('link');
          bootnap.rel = 'stylesheet';
          bootnap.href = './css/bootstrap-theme1.min.css';
          bootnap.media = 'only x';
          head.parentNode.insertBefore(bootnap, head);
          setTimeout(function() {
            bootnap.media = 'all';
          });
      }());
</script>

Before </body>

<script>
      (function() {
          'use strict';
          var xhr = new XMLHttpRequest();
          xhr.timeout = 4000;
          xhr.overrideMimeType('text/css; charset=UTF-8');
          xhr.onreadystatechange = function() {
              if (xhr.readyState === 4 && xhr.status === 200) {
                  var style = document.createElement('style'),
                      lastJS = document.getElementsByTagName('script')[2];
                  style.appendChild(document.createTextNode(xhr.responseText));
                  lastJS.appendChild(style);
              }
          };
          xhr.open('GET', 'https://fonts.googleapis.com/css?family=Roboto:400,700', true);
          xhr.send(null);
      }());
</script>

Result:

3

score3


The sweet spot (inlined critical above-the-fold css into the page itself):

Before </body>

<script>
    (function() {
        'use strict';
        var getAsyncFile = function(fileStr) {
            var xhr = new XMLHttpRequest();
            xhr.timeout = 4000;
            xhr.overrideMimeType('text/css; charset=UTF-8');
            xhr.onreadystatechange = function() {
                if (xhr.readyState === 4 && xhr.status === 200) {
                    var style = document.createElement('style'),
                        head = document.getElementsByTagName('head')[0];
                    style.appendChild(document.createTextNode(xhr.responseText));
                    head.appendChild(style);
                }
            };
            xhr.open('GET', fileStr, true);
            xhr.send(null);
        };
        getAsyncFile('./css/bootstrap-theme1.min.css');
        getAsyncFile('https://fonts.googleapis.com/css?family=Roboto:400,700');
</script>

Result:

4

The Desktop score went from 94 to 96 using the last example.


The browser is now fetching the bootstrap theme at 180ms, instead 220ms:

In the head

<script>
    (function(w) {
        'use strict';
        var xhrRunner = {
            firstRun: true
        };
        xhrRunner.getAsyncFile = function(fileStr) {
            var xhr = new XMLHttpRequest();
            xhr.timeout = 4000;
            xhr.overrideMimeType('text/css; charset=UTF-8');
            xhr.onreadystatechange = function() {
                if (xhr.readyState === 4 && xhr.status === 200) {
                    var style = document.createElement('style'),
                        head = document.getElementsByTagName('head')[0];
                    style.appendChild(document.createTextNode(xhr.responseText));
                    head.appendChild(style);
                }
            };
            xhr.open('GET', fileStr, true);
            xhr.send(null);
        };
        if (xhrRunner.firstRun) {
            xhrRunner.getAsyncFile('./css/bootstrap-theme1.min.css');
            xhrRunner.firstRun = false;
        }
        w.xhrRunner = xhrRunner;
    }(window));
</script>

Before </body>

<script>
    (function() {
        'use strict';
        xhrRunner.getAsyncFile(
        'https://fonts.googleapis.com/css?family=Roboto:400,700'
        );
    }());
</script>

Result:

5


Four days later:

Replaced the Roboto fonts with Helvetica.

Made seperate page that includes all the classes that my blog is using and used grunt-uncss. It looks like I've been using only 10% (13KB out of 126KB) of the bootstrap framework. The 13kb css is inlined into the page itself. My blog engine is separated on two parts, blog-engine 7.8kb and post-engine 75.5kb, instead serving one gigantic 90kb


Copy link

ghost commented Mar 6, 2017

I've developed and open sourced a new approach to handling asynchronous CSS and JS so I could use PhotoSwipe on my new blog. I hope you like it: https://www.npmjs.com/package/fetch-inject.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment