Why CSS is a key to performance

As you probably know, a browser can’t render the page if the Render Tree is not ready. And the Render Tree is the combination of DOM and CSSOM, hence anything that is blocking DOM or CSSOM construction – also blocks the page rendering.

Scripts can block DOM construction, but it’s easy to make them non-blocking with async and defer attributes. While it’s much more difficult to make your CSS asynchronous. As a result – your page will only render as quickly as your slowest stylesheet.

Critical CSS

A good option would be to implement “Critical CSS” pattern: you define a minimum set of styles needed to render the first screen (everything above the fold) to a user and then asynchronously load all other non-critical styles once the page is rendered. All critical styles you inline into style tag in the head, thus eliminating extra network request.

HTML

        <head>
  <style> /* inlined critical CSS */ </style>
</head>
<body>
  ...content goes here
   <script> loadNonCriticalCSS(); </script>
</body>
</html>
      
How to define critical styles for a page

How to define critical styles for a page

by Dmitry Salnikov

It’s an effective yet difficult strategy, especially if your website is highly dynamic or if you’re working with large or legacy codebase. It can be really hard to build and maintain styles when you use this approach.

Split CSS by media types

Another possible optimization would be to split your huge CSS bundle into smaller chunks based on the screen size, so that a browser downloads styles for the current screen size with the highest priority, blocking Render Tree construction. Any CSS not needed for the current context will be downloaded with a low priority, not blocking the first render.

Let’s take a look at an example: here we have a single CSS bundle with all styles. A browser can’t render the page until it fully loads the bundle, parses it and builds CSS Object Model.

HTML

        <link rel="stylesheet" href="all.css" />
      
Network waterfall when we load a single large CSS file

Network waterfall when we load a single large CSS file

by Dmitry Salnikov

Now, if we break it down to smaller CSS chunks, needed for specific screen sizes, we can declare media expressions using "media" attribute – and a browser will download only "base.css" plus what is necessary, depending on the current screen size ("large.css" in our example). Look at the "Priority" column in Chrome DevTools: everything that is not needed for the current screen size – loaded with the lowest priority and don’t block our Critical Path.

HTML

        <link rel="stylesheet" href="base.css" media="all" />
<link rel="stylesheet" href="small.css" media="(max-width: 703px)" />
<link rel="stylesheet" href="medium.css" media="(min-width: 704px) and (max-width: 1031px)" />
<link rel="stylesheet" href="large.css" media="(min-width: 1032px)" />
<link rel="stylesheet" href="print.css" media="print" />
      
Much healthier waterfall when we load with the highest priority only what is necessary for the current context, and load everything else later with the lowest priority.

Much healthier waterfall when we load with the highest priority only what is necessary for the current context, and load everything else later with the lowest priority.

by Dmitry Salnikov

Never use @import

Avoid using CSS @import as it is really, really bad for performance. Because we can’t load all imported stylesheets in parallel with the main CSS file.

Look at these steps a browser has to do when you use @import:

  • 1. Download HTML
  • 2. HTML requests CSS:

HTML

        <link rel="stylesheet" href="styles.css" />
      

Here’s where we’d like to be able to construct the Render Tree, but...

  • 3. CSS requests more CSS: @import url(imported.css);
  • 4. Build the Render Tree.

Now look at the waterfall, pay attention to the lack of parallelization:

When you use @import a browser can’t load imported stylesheet in parallel with your main stylesheet, so you end up losing time to finish loading and parsing of your main CSS file before it’s possible to start downloading the imported file.

When you use @import a browser can’t load imported stylesheet in parallel with your main stylesheet, so you end up losing time to finish loading and parsing of your main CSS file before it’s possible to start downloading the imported file.

by Dmitry Salnikov

If you simply add your imported stylesheet as a separate link tag...

HTML

        <link rel="stylesheet" href="styles.css" />
<link rel="stylesheet" href="imported.css" />
      

...a browser can load them both in parallel, significantly improving performance:

Without imports can start downloading all stylesheets in parallel, significantly boosting the first render performance.

Without imports can start downloading all stylesheets in parallel, significantly boosting the first render performance.

by Dmitry Salnikov

Don't place <link> before async scripts

Any synchronous script’s execution is blocked while a CSS file is being downloaded and processed. This is done on purpose in case if the script requests the page’s styles. And for that, a browser needs to construct CSSOM.

HTML

        <link rel="stylesheet" href="slow-styles.css" />

<script>
  console.log(`
    I will not run until
    slow-styles.css is downloaded.`
  );
</script>
      

A browser will not execute script if there is any currently in-flight CSS.

If your stylesheet is slow – all scripts are not running and it has a dramatical impact on the page performance. Let’s take a look at an example:

HTML

        <link rel="stylesheet" href="app.css" />

<script>
  var script = document.createElement('script');
  script.src = "analytics.js";
  document.getElementsByTagName('head')[0].appendChild(script);
</script>
      

We’ve completely lost any parallelization:

Stylesheet’s loading blocks the execution of scripts, so we end up losing parallelization until the CSSOM is constructed.

Stylesheet’s loading blocks the execution of scripts, so we end up losing parallelization until the CSSOM is constructed.

by Dmitry Salnikov

Now let’s try to swap stylesheet and script and look at the result:

HTML

        <script>
  var script = document.createElement('script');
  script.src = "analytics.js";
  document.getElementsByTagName('head')[0].appendChild(script);
</script>

<link rel="stylesheet" href="app.css" />
      
Moving the script before the stylesheet link made the waterfall much healthier: now we load both resources almost in parallel.

Moving the script before the stylesheet link made the waterfall much healthier: now we load both resources almost in parallel.

by Dmitry Salnikov

So we can definitely say: if your script has no dependency on CSS, place it above your stylesheets. It can make a huge impact on performance, depending on the page complexity.

However, in the latest Chrome (v75), I couldn’t reproduce this behavior: the CSS stylesheet is loaded always first, regardless of whether it goes after the script or before. In Safari (v12), on the contrary, I could reproduce and, as you see on screenshots, the order really matters for performance.

Progressive rendering

For SPA when we create a single giant stylesheet bundle, we encounter the following problems:

  • We download much more CSS than we need (any page uses only a small part of the styles you have in the bundle);
  • It's bad for caching (If we change styles for a component on another page we need to download the whole bundle again);
  • Blocks the rendering (we have to wait till the stylesheet is fully downloaded, even if we need only a small part of its styles).

With HTTP 2 and a proper build configuration we can fix the issues with cache and loading too much CSS, splitting the bundle into smaller chunks, preferably a stylesheet per each component:

HTML

        <head>
  <link rel="stylesheet" href="core.css" />
  <link rel="stylesheet" href="nav.css" />
  <link rel="stylesheet" href="article.css" />
  <link rel="stylesheet" href="ads.css" />
  <link rel="stylesheet" href="footer.css" />
</head>
      

Now we load only styles needed for the components on the page, plus caching becomes very effective. But still, we block the page rendering with our stylesheet requests :(

But here’s a trick: link rel="stylesheet" only blocks the rendering of a subsequent content, rather than the whole page (works in IE/Edge, Firefox and Chrome since v69). It means we can build our page like this:

HTML

        <head>
  <link rel="stylesheet" href="core.css" />
</head>

<body>
  <link rel="stylesheet" href="header.css" />
  <header class="header">
    <link rel="stylesheet" href="nav.css" />
    <nav class="nav">...</nav>
  </header>

  <link rel="stylesheet" href="article.css" />
  <main class="article">...</main>

  <link rel="stylesheet" href="footer.css" />
  <footer class="footer">...</footer>
</body>
      

In 2016 Jake Archibald wrote the blog post "The future of loading CSS" about this progressive rendering technique, saying that they're going to implement this behavior in Chrome.

The famous blog post by Jake Archibald about progressive rendering technique he wrote in 2016.

The famous blog post by Jake Archibald about progressive rendering technique he wrote in 2016.

by jakearchibald.com

The progressive rendering looks very effective and promising. I’m surprised it’s not widely known or used. If you decide to implement it in your website, please give me a notice, I’d like to look at it and hear from you about performance improvement.