Ruby + Chrome’s coverage tool: Generate only used CSS (@media supported)

One of the issues I often face, whether I’m optimizing a page for performance or when gutting old pages is the need to get only the used CSS on a page. Chrome has a tool which can show you how much of your JS and CSS is “used,” which is tremendously helpful. It can tell you when your gratuitous 955 kB CSS file only has 1.5% coverage, meaning you could shave off 98.5% of that file, a huge win for your visitors on a data budget.

If you can download only the CSS rules that are applied to a page, you could create a “critical CSS” file, a technique for inlining only the CSS that applies to above-the-fold content and loading the full CSS file once the page is fully loaded. This technique makes your site appear to load instantly with no FOUC. Additionally, if you’re doing a major site redesign, it could be helpful to preserve the styling of legacy pages by including a legacy-only CSS file with absolutely no bloat. This avoids having a hoard of old CSS files (probably in a directory called “old”) where you’re too scared to delete anything for fear of breaking the pages.

I found the tool to be very helpful, especially when looking at CSS I never wrote. But a few things are lacking from this feature:

  • No built-in way to download only the used CSS to a file.
  • It doesn’t work across pages — it can’t tell you what CSS is used across your whole site.
  • It doesn’t really know about rules within media queries unless they’re activated. This is likely by design. If you test the coverage of your page on mobile, it’s going to consider those rules inside desktop media queries unused.
  • @media declarations are considered un-used.
  • @font-face declarations are considered un-used.
  • The tool doesn’t know about all your dynamic content, obviously. If certain CSS is only activated after an AJAX call or :hover, you need to activate each of those scenarios on the page, which can be tedious and intimidating.

Though these specific features are lacking, Chrome’s tool could save you arguably days of work if doing it by hand. When I discovered this tool, I figured hey, I can write a script to take care of the rest of my needs. The hard part is done!

This guide uses Ruby to fill in a few of the gaps mentioned above, and creates a workflow for generating that beautiful 99% coverage CSS file. It includes a little bit of manual intervention, but trust me, it’s pretty agreeable. I tried to make the script easy enough to follow so that you could implement a similar one in your language of choice.

There exists Chrome plugins which let you download the used CSS to a file, but when I checked this weekend (2020-06-13), each of the plugins that offered this feature had bad reviews, and weren’t able to get around the media queries problem. It was enough to make me decide to write it myself.

The basic concept here involves exporting the coverage as JSON, and parsing it to create a final CSS file containing only the used rules. Once the script is written, I’ll go over a workflow you can follow to bring this all together. Let’s get started!

Getting Ready

First we need to get a coverage file exported. I’m going to head over to one of my sites and pop open the Chrome inspector to see what the coverage is like. I’m going to:

  1. Open an inspector
    Chrome context menu
  2. With the elements tab open, and a console below, click the kebab menu to the right of the console and select “Coverage” to enable the panel.
    Chrome console kebab menu Chrome enable coverage menu
  3. With the Coverage menu open, look for the refresh-looking icon next to the dropdown labeled “Per Function.”
    Chrome Coverage start instrumenting
    When you click this, Chrome will start recording code coverage.
  4. Now it’s time to start playing around with your page to activate all the scenarios which would trigger CSS you know about. Resize the viewport to activate media queries. Hover over things you know have :hover rules. As you do this, Chrome will keep an account of what CSS is actually being used on the page. In that same Coverage menu, you can now see a list of files populate, ordered by most amount of “unused bytes.” For me, I’m looking at this file at the top which has 94.4% unused bytes:
    Unused bytes
    DISSSSS-GUSTING!!! That’s a 312 kB file loaded on a page where I’m only using ~17 kB. For a site I built, I’d expect better!
  5. I’m going to hit that Export button near where I started recording to start building my streamlined CSS file.
    Export button
    That will export a file I’m going to name coverage.json to keep things simple. Place your file in a directory where you will create the Ruby script.

We now have what we need to start building the script.

The Script

When you export Chrome’s coverage as JSON, it’s going to include an array with references to each of the used files, a schema that looks like this:

{
  "url": "http://localhost:8080/assets/style.css",
  "ranges": [
    {
      "start": 26,
      "end": 49
    }
    ...
  ],
  "text": ".rule-a { color: blue; }n.rule-b { color: red; } ..."
}

Though it gives you the URL for the file, the fact that it gives you the entire file contents makes this even easier. We just need to loop through the ranges and use them to select snippets from the included text.

We’ll assume at this point that you have a file in the same directory called coverage.json. We’ll start by opening that file and looping through each CSS file in the list to extract the ranges from the contents.

require "json"
# Open a file in the same directory called only-used.css
File.open("only-used.css", "w") do |new_file|
  # Open `coverage.json` and parse it as JSON
  # Begin looping through the assets
  JSON.load(File.open("./coverage.json")).each do |file|
    # Skip the asset unless it has a CSS extension
    next unless File.extname(URI.parse(file["url"]).path) == ".css"
    # Begin looping through the ranges
    file["ranges"].each do |range|
      # Select the snippet within the text that contains used CSS
      snippet = file["text"][range["start"]...range["end"]]
      # Add it to the file
      new_file << "\n#{snippet.strip.gsub(/\s+/, " ")}"
    end
  end
end

I named this file extract.rb. Execute the file by running:

> ruby extract.rb

The above snippet will create a file in the same directory called only-used.css, which contains the rough draft of what we want. Right away, I can see my file contains valid CSS, but it doesn’t contain any media queries. Whoops! This is because Chrome currently doesn’t count media queries as “used” CSS. Well, that’s just like, their opinion … man. No bother. We can code around this drawback and write some RegEx to search for and capture the nearest media query where applicable, then prepend it to the correct rules. Let’s edit the script.

require "json"
# Open a file in the same directory called only-used.css
File.open("only-used.css", "w") do |new_file|
  # Open `coverage.json` and parse it as JSON
  # Begin looping through the assets
  JSON.load(File.open("./coverage.json")).each do |file|
    # Skip the asset unless it has a CSS extension
    next unless File.extname(URI.parse(file["url"]).path) == ".css"
    # Begin looping through the ranges
    file["ranges"].each do |range|
      snippet_start = range["start"]
      # Select the snippet within the text that contains used CSS
      snippet = file["text"][snippet_start...range["end"]]
      # Scan for a media rule in the text that is nearest to the beginning of the snippet
      nearest_media_rule = file["text"][0..range["start"]].to_enum(:scan, /(@media[^{]+{)/).map { Regexp.last_match }.last
      # If a media rule exists
      if nearest_media_rule
        # Record the end position
        media_rule_end = nearest_media_rule.end(0)
        # Record the actual media rule
        media_rule = file["text"][nearest_media_rule.begin(0)...media_rule_end]
        # With all the text from the end of the media rule to the start of the snippet
        media_to_snippet = file["text"][media_rule_end..snippet_start]
        # Look for open and close brackets. If there is an equal balance of 
        # open and close brackets, we know this particular @media rule applies
        # to our snippet of used CSS
        if media_to_snippet.scan("{").count == media_to_snippet.scan("}").count
          # So let's append that media rule to the used CSS, along with a close bracket
          # at the end of the line.
          snippet = "#{media_rule} #{snippet} }"
        end
      end
      # Add it to the file
      new_file << "\n#{snippet.strip.gsub(/\s+/, " ")}"
    end
  end
end

Whoa now! Once we execute this script, I can see it’s now pulling in media queries that make sense. Check out the way it builds Bootstrap’s .container class.

.container{width:100%}
@media (min-width: 576px){ .container{max-width:540px} }
@media (min-width: 768px){ .container{max-width:720px} }
@media (min-width: 992px){ .container{max-width:960px} }
@media (min-width: 1200px){ .container{max-width:1140px} }

Not only does this make sense, but it teaches us a thing or two about Bootstrap’s organization. Yay media queries! Crisis averted!

Multi-page problem

Now a few of our problems from above have been solved, but what about when we want to test between pages? This is where some manual work is involved, and you’re not gonna like it. What I did in this scenario is ran a similar test-everything-on-the-page-then-export-a-file process on each page I knew to be unique. Since each page now has it’s own exported JSON file, I placed those into a directory called /coverages and set up a loop with Ruby to read the files sequentially:

require "json"
# Open a file in the same directory called only-used.css
open("only-used.css", "w") do |new_file|
  # Get the list of files in the `/coverages` directory
  Dir.foreach("./coverages") do |filename|
    # Skip if the file is not JSON
    next unless File.extname(filename) == ".json"
    # Open the file and parse it as JSON
    # Begin looping through the assets
    JSON.load(File.open("./coverages/#{filename}")).each do |file|
      # ... you know the rest
    end
  end
end

This will create a file that may have a LOT of duplicates, depending on how many pages you exported. The last step is to get rid of duplicate lines in the generated only-used.css file. The key is to keep the lines in order while removing duplicates. Don’t sort the lines alphabetically or you’ll totally lose the cascading nature of the original stylesheets. To do this, I just used a VSCode plugin for removing duplicate lines. It left the order alone. It worked like a charm and in the end I was left with only the used CSS across multiple pages.

Where it falls short

Chrome’s coverage tool doesn’t filter out unused selectors if part of a used rule. For example, when working with a CSS grid, you may have a selector that applies to all columns:

.col-1,
.col-2,
.col-3,
...
.col-12 {
  float: left;
}

If you happen to only be using .col-3, it’s not going to remove the rest of the .col-1, .col-2 and so on. In cases like this, it’s going to take manual intervention if you truly want to get rid of any unused selectors.

Relative assets in CSS aren’t guaranteed to be preserved. The coverage tool knows about which rules are used across multiple CSS files. If those files use relative asset urls such as:

/* File A */
.bg-thatched { background-image: url("../img/thatched.png"); }
/* File B */
.hero-header { background-image: url("../../img/hero.png"); }

Those rules will be looking in directories given their original layout, which may not be the same. My suggestion would be to not use relative assets, or at least put all the image assets in one directory so referencing is consistent between files.

Summary

I’m still really glad this tool exists, and I feel like a few extra lines in a script solved the majority of my problems. It would be nice to automate some of the testing with a tool like ChromeDriver, but I suggest you cross that bridge when you get there. For the purpose of exporting a single page or up to 10 pages, this should be sufficient.

3 responses to “Ruby + Chrome’s coverage tool: Generate only used CSS (@media supported)”

  1. Riccardo says:

    Hi, thanks for the article. When I try to run the script I see this error: extract.rb:8:in `block (2 levels) in ‘: uninitialized constant URI (NameError)

Leave a Reply

Your email address will not be published. Required fields are marked *