Skip to main content Accessibility Feedback

Modular CSS and different ways to structure your stylesheets

This week, I’ve written about classless vs. class-based CSS, and how I approach CSS architecture. I’ve also written a fair bit recently about how I’m starting to consider build tools and anti-pattern.

This has led to a few conversations about how to structure CSS and and work with modular files if you’re not using a compiler like Sass. Today, I wanted to talk about that.

Let’s dig in!

Modular files and build tools

One of the big benefits of using a build tool like Sass or css-nano is that you break your CSS up into smaller, more modular files, then combine them into one file for deployment.

The Sass file for Kraken, my CSS boilerplate, looks like this…

@import "config";
@import "components/reset";
@import "components/grid";
@import "components/typography";
@import "components/code";
@import "components/buttons";
@import "components/forms";
@import "components/tables";
@import "components/overrides";
@import "components/print";

This let’s me work with modular files, but deploy a single CSS file. But why do we care about combining modular CSS files in the first place?

Because web performance

Historically, loading a bunch of smaller files like this was bad for web performance.

In the era of HTTP/1, browsers could only load two files at a time, and every HTTP request involves a bit of a handshake and adds some latency. Combining smaller files into one big one would (often significantly) reduce the total load time for files.

Now, most servers support HTTP/2, which can handle many simultaneous downloads concurrently.

I’ve been wondering if the advantages of concatenation (combining smaller files into one) still hold up in the age of HTTP/2. So I ran some tests!

Let’s look at two alternate approaches for working with modular CSS, and see how they do against a single, combined CSS file.

Approach 1. Loading a bunch of modular files

Because CSS is global in scope (generally speaking) and cascades, you can load modular CSS files individually in the order you want them to cascade, and achieve the same visual effect as having your styles all in one big file.

<link rel="stylesheet" href="dist/_config.css" type="text/css">
<link rel="stylesheet" href="dist/_reset.css" type="text/css">
<link rel="stylesheet" href="dist/_grid.css" type="text/css">
<link rel="stylesheet" href="dist/_typography.css" type="text/css">
<link rel="stylesheet" href="dist/_code.css" type="text/css">
<link rel="stylesheet" href="dist/_buttons.css" type="text/css">
<link rel="stylesheet" href="dist/_forms.css" type="text/css">
<link rel="stylesheet" href="dist/_tables.css" type="text/css">
<link rel="stylesheet" href="dist/_overrides.css" type="text/css">
<link rel="stylesheet" href="dist/_print.css" type="text/css">

This keeps code modular, but can also make loading CSS across various pages more difficult. If you add another CSS module, you need to go updated your link elements everywhere.

Harry Roberts from CSS Wizardry tested this approach, and found it was substantially worse for performance.

With many small files, as ‘recommended’ in HTTP/2-world, we got a 1,524ms css_time and transferred 60KB of CSS. Put another way, the HTTP/2 way was about 1.4× slower and about 3.3× heavier.

He ran his test with Bootstrap, which is pretty big. I’ll be running my own tests with Kraken, which is substantially smaller and lighter (by design).

🙊 Spoiler: I got different results than Harry.

Let’s look at another approach.

The CSS @import rule

CSS has had native module imports for years, long before JavaScript, with the @import rule.

With this approach, you have a single CSS file you load on your site, and that file imports your modules.

@import "_config.css";
@import "_reset.css";
@import "_grid.css";
@import "_typography.css";
@import "_code.css";
@import "_buttons.css";
@import "_forms.css";
@import "_tables.css";
@import "_overrides.css";
@import "_print.css";
<link rel="stylesheet" href="dist/main-import.css" type="text/css">

This has long been considered an anti-pattern, for the same reason native nested imports are an anti-pattern in JavaScript. Waterfall downloads hurt performance.

However, for many popular JS library CDNs, the file you load contains nothing by a handful of import statements for various modules.

I’m curious if using this approach with just one level of @import statements (no waterfalls below the first file) has an acceptable level of performance.

The Test Results

For this test, I created three HTML files…

  1. Concatenated CSS. This loads concatenated, unminified CSS file for Kraken.
  2. Split CSS. I broke the compiled CSS file into modular files, and loaded each one with its own link element.
  3. Imported CSS. I load a single CSS file, which uses @import to import the modular CSS files.

You can download the test suite on GitHub.

I put all three files (and the CSS to go with them) on a server, then ran each file through WebPageTest.org using the following settings…

  • Mobile 3G
  • Chrome Browser
  • Mumbai, India (my server is located in NYC, no CDN)
  • First and Repeat View

Here are the results…

File First Byte Start Render
Concatenated 1.996s 2.9s
Split CSS 1.924s 3.2s
Imported CSS 1.864s 3.3s

On repeat view, all three files had an identical start render time of 2.2s thanks to browser caching.

As you can see, splitting the code up resulted in 300-400ms of rendering delay. The performance difference between split files and using @import was substantially less than I expected for a slow 3G connection.

Conclusion

Obviously, a concatenated file is still the most performant for people on the slowest connections and oldest devices.

However, about 300ms of extra delay on a 3G connection halfway around the world from the server could potentially be an acceptable tradeoff for being able to use a modularized code base without build tools.

A start render time of just over 3 seconds on mobile is still well within my acceptable performance threshold. When you step up to a 4G connection, the difference in load time drops sharply.

Layer in a CDN to place those files closer to where the user is located, and you can probably drop the First Byte type substantially as well.

Which is all to say that I think using CSS @import with concurrent (not waterfall) imports is potentially a viable strategy today. I’d strongly recommend running your own tests with your own code base before shipping with this approach, however.

What do you think?