January 11, 2019

New Website and Tailwind Performance Hell

First proper work journal entry, exciting! My plan is to do these every week, but this one covers the last two weeks as I'd like to get the entire year in here.

Rebuilding my personal site

Last week (Dec 31–Jan 1) was a short week for me as we were wrapping up the holidays, but I did manage to design (with a lot of input from Steve), build, and launch my new personal website, which is where you're reading this post now.

Everyone's personal website these days is a blog — a site centered around a bunch of articles, listed in reverse chronological order, where the recency of a post determines where it's featured on the site. When I was a kid, websites were just websites, not blogs. Pages were just pages, not posts, and the content was organized by hand, not by date.

My previous site was a blog, and it sucked. So I built this new one, where I can just create pages for anything I want, and organize the content however it makes the most sense to me. All of my old blog posts are still here, but they're in the archives, not right on the homepage.

I have a few more things I need to do like update the actual article pages (they still use the old layout and CSS), add some content to the screencasts page, and try to decide on some more consistent link styling (there's like 5 different treatments on the site right now), but overall I'm really happy with where it's at.

The site is built with Jigsaw and Tailwind CSS, and hosted on Netlify.

Refactoring Tailwind internals and the mystifying performance issue

A while ago I added a plugin system to Tailwind that makes it easy to add new classes using a JavaScript API. This has been great, but it meant that inside Tailwind itself there were two ways to register new classes:

  1. The original internal-only system that all of the built-in utility modules like backgroundAttachment, display, maxHeight, whitespace, etc. use.

  2. The new plugin system that end users use.

In v0.5.0, the .container class was converted to a built-in plugin because it was very much a special case and made the internals code more complex.

This week I wanted to convert all of the built-in utility modules to use the plugin system under the hood to hopefully make it possible to delete a ton of code, and make the plugin system the One True Way™ that new classes are registered inside of Tailwind.

I spent a few days making all of the mind-numbingly tedious changes necessary to convert all 50 of the existing utility modules into plugins, all while keeping the tests passing, and then ran a full build to double-check these changes hadn't created any performance issues.

I expected things to be a little bit slower because the plugin system uses postcss-js which means there's an extra step transforming that syntax into raw PostCSS nodes which wasn't there in the legacy system that used the PostCSS API directly.

But it wasn't just a little bit slower, it was like 7-8x slower.

A full babelify-and-generate-all-of-our-CDN-files build took about 5 seconds on my machine on the master branch, and on the new modules-as-plugins branch it took more like 40 seconds.

My first thought was "shit, this postcss-js stuff is way slower than I expected," but after some good old console.log debugging, I found that using postcss-js was actually a tiny bit faster than what I had been doing in the legacy system. What the fuck?!

I asked on Twitter if anyone had any recommendations on how to profile one-off Node scripts, and Romain Lanz suggested a tool called 0x, which was exactly what I needed.

I ran it against both branches to generate a flamegraph and find out where most of the time was being spent, and to my surprise, my code hadn't gotten slower at all. The slow-down was actually happening in the PostCSS stringifier — the code responsible for taking a tree of PostCSS nodes and generating the actual CSS output as a string.

I hadn't changed any of my dependencies between the two branches, so I knew something I was doing in the new branch was making it much more difficult for PostCSS to stringify the CSS.

I spent all day on Thursday trying to figure out what the difference was by trying to compare the nodes I was generating on the master branch to the nodes I was generating on the new branch, but I couldn't for the life of me see a difference. Logging the nodes from both branches to disk just created two files that were the exact same length, with the only difference being the order of a few properties on the objects.

I was pretty defeated at this point. I didn't want to revert all the work I had done, but I also couldn't use any of this code if it slowed the builds down this much.

On a long shot, I reached out to Andrey Sitnik, the creator of PostCSS, hoping I could pay him for an hour of his time to help me track down the issue. He graciously offered to help me for free, and we scheduled some time to look at the problem on Friday morning.

Debugging with the creator of PostCSS

I got on a call with Andrey at 8am my time and showed him the issue. He had a few ideas to try first to try and narrow down the problem (like disabling sourcemaps), but nothing exposed the root cause.

In the end, we tracked down the source of the problem by editing the compiled PostCSS source code in the node_modules directory — returning empty strings in different areas of the stringifier to effectively disable certain blocks of code until we could figure out where the slowness was happening.

We eventually discovered that the stringifier was doing a lot of work to simply figure out "should I include a semicolon after this declaration?"

You see each PostCSS node contains a raws object, which stores information like how much indentation exists before the node, how many spaces there are after the opening /* in a comment, or if the declaration has a semicolon at the end.

If any of the information in that raws object is missing when PostCSS tries to stringify the node, it scans the entire CSS tree to try and detect the style used for other nodes so it can apply that same style to the current node, in an attempt to make the output look as consistent as possible.

If it can't find any precedent, it falls back to a default value, and makes note in a cache that that default value should be used going forward.

The performance problem I ran into was the result of PostCSS not storing a default value for semicolons in the cache.

The postcss-js library that the plugin system uses generates an empty raws object for every node, which makes sense since the nodes weren't parsed from a CSS string. This shouldn't be a problem in theory, but because PostCSS wasn't caching a default value for whether to include optional semicolons, it meant that for every single class Tailwind generates, PostCSS was scanning the entire CSS tree for semicolon information, over and over and over and over again.

The fix was adding one line to the PostCSS stringifier: semicolon: false.

Andrey published a fix for this in PostCSS 7.0.9, but since Tailwind is currently on PostCSS 6, Andrey helped me patch it from userland by providing our own raw cache that included the default semicolon value.

The performance problem is gone, and everything works beautifully.

Tailwind contributor Matt Stypa was also able to track down the problem, and documented his approach here for anyone interested.

Big thanks to both Andrey and Matt for being willing to help me — being stuck on this problem was turning me into a horrible miserable person.

Next week

Few things I've got on my plate for next week:

  • Finish this refactoring (still have to go through and delete all the dead code)
  • Edit and publish the next Full Stack Radio episode — I recorded a great conversation with Sam Selikoff about SPA architecture
  • Work on some color palette and component gallery updates for Refactoring UI
  • Implement some changes Steve has been designing for the Refactoring UI landing page
  • Start planning a screencast I want to create to help promote Refactoring UI, where I use some of the resources included with the book to build a really polished design with Tailwind