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.

@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