Immutable CSS: Optimizing Performance and Minimizing Size

Don Abrams
4 min readApr 15, 2016

--

I have an obsession with making the web feel seamless. I care way too much about minimizing the latency of web pages. I want the page to load, display, and be interactive as quickly as possible.

I’m equally obsessed with iterating quickly. It doesn’t matter how fast my web pages are if I can’t add or remove a feature in a timely fashion.

To help with this at Craftsy, we developed babel-plugin-encapsulate-jsx and encapsulate-css. This allowed us to quickly and confidently add and remove components to a page because it guaranteed that each component couldn’t affect the CSS of another component (well, except for opacity and z-index). However, as the number of components grew, the size of our CSS stylesheets grew to around 1/3 the size of our javascript bundle (which included all sorts of libraries including lodash and react). That’s a pretty substantial amount of CSS that has to load before the page can render.

We took an approach of just concatenating each component’s CSS. When I looked at the concatenated CSS, several rules were repeated over and over again, mostly due to heavy use of mixins. I figured it would be easy to combine rules and minimize the CSS size.

I was wrong.

Problems with Optimizing CSS

So everyone, optimizing CSS is hard. Really hard. Why? Well let’s start by trying to optimize the below stylesheet:

.yay {
font-size: 1.5em;
}
.boo {
font-size: 1.5em;
color: "red";
}
.beep {
font-size: 2em;
}

We can combine the font-size attributes of .yay and .boo.

.yay,
.boo {
font-size: 1.5em;
}
.boo {
color: "red";
}
.beep {
font-size: 2em;
}

Easy enough. If this happened 30 times in a sheet, we’d save a ton of bytes. Well, what if we moved .beep between .yay and .boo?

.yay {
font-size: 1.5em;
}
.beep {
font-size: 2em;
}
.boo {
font-size: 1.5em;
color: "red";
}

Think we can still combine .yay and .boo? Nope:

<div class="beep boo">I should be 1.5em, but if we combine .boo and .yay we get 2em instead!</div>

This is why postcss-merge-rules only merges or split adjacent selectors.

In fact, because of this difficulty, people have started to take SASS out of their workflows entirely. (I can’t find it but I recently read an article where the writer stopped using semantic classes/SASS and instead had a very minimalistic set of classes they applied in order to keep the stylesheet super tiny.)

Immutable CSS

Let’s introduce a concept here. If a CSS rule is never overridden by another rule when applied to a DOM node, then we’ll call it immutable.

Why does immutability matter? When CSS rules are immutable we can merge and split non-adjacent rules.

There’s an entire library dedicated to discovering mutations between a CSS library and some base rules called immutable-css. But it doesn’t tell you how to write immutable CSS.

Writing Immutable CSS

There are two rules you can follow when writing CSS and HTML that guarantee immutability:

  1. One class per DOM node
  2. No cascading rules

OK:

<div class="foo"><div class="bar"></div></div>.foo {
color: "red";
}
.bar {
color: "blue";
}

Not OK:

<div class="foo bar"></div>.bar {
color: "blue";
}
.foo .bar {
color: "red";
}

There are some properties that naturally cascade, including z-index and opacity that you’ll just have to be careful of.

I will be writing (or finding) eslint and sasslint rules that automate verification of this soon. Then, I’ll be able to merge non adjacent rules safely!

Fortuitously, following these rules also results in very performant CSS, especially compared to using cascading selectors.

Dead Ends

The only one class per node rule is also overly general. Technically, you could use two classes on a DOM element providing none of the rules in those classes overlap. Example:

<div class="foo bar"></div>.foo {
color: "red";
}
.bar {
font-size: 2em;
}

I think this is rarely needed and adds a ton of complexity without much value. I won’t allow this in the eslint and sasslint rules, as it requires the HTML checker to know about the CSS. Keep it simple, and forget this exception exists.

Wouldn’t it be nice if we could write any CSS and HTML we wanted? And still have immutability guarantees?

Well, we could create a tool that transforms any CSS and HTML into a form that conforms to those two rules. As far as I know, no one has done it; it would be a huge undertaking. I personally don’t think it’s worth it as cascading makes CSS harder to read and being able to look at the DOM and figure out exactly what CSS rule to change is kinda awesome. Additionally, proper use of SASS mixins plus non-adjacent merge/split will have the same end result.

Conclusion

Follow these 2 rules when writing CSS and HTML:

  1. One class per DOM node
  2. No cascading rules

Enforce these rules with linters. If you do this, you can add a PostCSS non-adjacent merge/split to minimize # of selectors.

This Could All Be For Nothing

Gzipping may handle most of this already. Additionally, depending on the browser implementation, splitting up rules may result in a performance penalty.

--

--

Don Abrams

I make software; mostly for the web; mostly frontend.