Optimizing CSS by removing unused media queries
As performance is important for both users and SEO reasons, we’re taking some steps for Zoover.nl to improve those metrics. This is part one of a series where we discuss some of the improvements we are making.
One of the (supporting) metrics that we are trying to improve for this trimester is the amount of CSS we ship to the browser. CSS is absolutely critical for the (perceived) performance of your website: any render is delayed until your stylesheets have been fully loaded. We’ve given ourselves a budget of 50 kilobytes of uncompressed CSS: not entirely coincidentally the same limit that AMP imposes on stylesheets.
Now, given your perspective, that might be a highly ambitious goal, or one that is easily achieved. For us, it is the former. Here’s why:
- We use CSS Modules, which doesn’t encourage re-use;
- We do not have a lot of design system discipline, so we have a bunch of small variations of seemingly similar components & styles;
- Our pages have lots and lots of content;
- We continuously have A/B tests running which adds more bloat to the page;
- We have to support a myriad of devices and browsers.
To keep our CSS small, we already have a few optimization steps in place:
- Code-split CSS with mini-extract-css-plugin
- Optimize & minify CSS with optimize-css-assets-webpack-plugin
- Use browserslist with autoprefixer to prevent unnecessary vendor prefixes
Unfortunately, that’s not enough to keep it under 50kb on some of our pages. There are some things that we can do in the long term: double down on a design system and encourage re-use, and combat feature bloat. These options require however a significant investment, and buy-in from stakeholders. As a short-term solution, we decided to explore the possibility of generating different variations of our stylesheets, with only styles that are applicable for the current device and viewport. That means removing any styles inside of irrelevant media queries.
Exploring the options
First things first: a
link element has a
media attribute, right? You can use this attribute to tell the browser to only apply a given stylesheet when the viewport matches the specified media query. Great, we can probably use that to download only the stylesheets we ne —
Nope. Wrong. The browser will still download stylesheets with a media query that doesn’t match, even though it doesn’t plan to use it. Here’s a test from Scott Jehl that demonstrates this behaviour: http://scottjehl.github.io/CSS-Download-Tests/
So, what else do we have left? Well, data. At Zoover we use screen classes: extra small, small, medium, and large. Roughly, the first two apply to phones, the third one to tablet, and the last one to desktop. That leaves the possibility open of using the
User-Agent header to detect what kind of device is requesting the page, and then loading a stylesheet that is stripped of media queries that apply to screen classes not relevant to that device. However, because you have devices in all shapes & sizes, there’s no guarantee that a phone will never have a “medium” viewport, or that a tablet will never have a “small” viewport. Which brings us to step 1:
1. Get the data
As a first step, we needed to get an idea of how closely device types match the expected screen class. Since we are already tracking page loads in the browser, it was easy for us to add some metadata to our metrics about the device type, the viewport dimensions, and the screen class. We can then see the distribution of screen classes for a given device type. Here’s a Kibana visualization of screen classes on desktop:
Now, as you can tell, pretty much all of it is in the
lg screen class. About 2% is
sm are below 1%. That means we don’t need media queries for the latter screen classes in our CSS file. Similarly, for tablet we can drop
xs and for mobile we can drop both
lg and (surprisingly)
2. Pre-process Webpack’s CSS output
Now, we need some way to hook into Webpack’s compilation process. The seemingly obvious way to do this is to write a Webpack plugin. A Webpack plugin allows you to tap into certain phases of the process. The phase that we need is
additionalAssets. There might be a more appropriate phase here, but this… works. The catch here is that we need to run after mini-css-extract-plugin runs, but before optimize-css-assets-webpack plugin, as we want to hand our own generated CSS off to the minification process that we already have. Here’s how the plugin looks, roughly:
Pretty easy! Now we can send all CSS to our little tool that will generate even more efficient CSS.
3. Remove unused media queries
Now that we have some CSS, we need to process it a bit. We need a function that accepts named ranges, and returns CSS for every range that is stripped of media queries that are not applicable. The perfect tool for such a processor would be PostCSS, which is basically Babel for CSS. Now, of course someone has already written something which does exactly what we need, but because it’s really easy to do things with PostCSS, and for the sake of my own ego, we’re just going to write our own:
There we are. We used
css-mediaquery for parsing and evaluating media queries in Node (in a browser, you can use
window.matchMedia). We’ve also made sure the order of the input CSS file is respected. We can then return this output back to the Webpack plugin that we wrote before, and all our device-type specific CSS will be written to disk.
4. Using device type detection to serve the CSS files
Now, as a last step, we need to serve the appropriate CSS file to the browser. At this point we have already created device-type-specific CSS files. Now, when a request comes in, we can use the
User-Agent header to detect what kind of device we’re serving. Then, we use this information to serve the trimmed down CSS file for the device type that we created in the previous step:
There’s one odd thing here: we set a
data-href attribute on the
link element. This is because
mini-extract-css-plugin embeds a little runtime that takes care of loading stylesheets as the browser navigates your application. That runtime needs to check whether a stylesheet was already loaded. The way that it does so, is by checking the
href attribute of already present stylesheets. Luckily for us, it falls back to
data-href, which means that we can trick Webpack into thinking the stylesheet it is looking for is already loaded. If we don’t do this, Webpack’s runtime will immediately inject the original stylesheet into the document, negating any improvements that we were hoping to get out of this change. Here’s how that looks for desktop:
Our CSS files are approximately 10% smaller. Here’s a result of one of our performance tests (where we compare feature branches to our master branch):
In the grand scheme of things, 10% is pretty small, and in a lot of cases, not worth it. It will not be the biggest win we can make either. However, it gives us a little more room to breathe as we work on more structural solutions for our resource bloat, it was fun to write and it cost me more time to write this article than to implement this optimization. That might be my writer’s block though 😀.
At the end of this week, we’ll be attending the performance.now() conference; pretty sure we will have enough material then for the last part of the year, so stay tuned!