Andrew Welch · Insights · #css #tailwind #frontend

Published , updated · 5 min read ·


Please consider 🎗 sponsoring me 🎗 to keep writing articles like this.

Speeding Up Tailwind CSS Builds

Learn how to opti­mize your Tail­wind CSS PostC­SS build times to make local devel­op­ment with Hot Mod­ule Replace­ment or Live Reload orders of mag­ni­tude faster!

Update: Tail­wind Labs has released the Tail­wind­C­SS JIT that pro­vides excel­lent per­for­mance gains. It’s still an alpha ver­sion at the time of this writ­ing, but it’s worth a look instead of imple­ment­ing the CSS split­ting” described in this article.

Tail­wind CSS is a util­i­ty-first CSS frame­work that we’ve been using for sev­er­al years, and it’s been pret­ty fan­tas­tic. We first talked about it on the Tail­wind CSS util­i­ty-first CSS with Adam Wathan episode of dev​Mode​.fm way back in 2017!

We use it in the base of every project we do, as part of a web­pack build process described in the An Anno­tat­ed web­pack 4 Con­fig for Fron­tend Web Devel­op­ment article.

One issue that we’ve run into recent­ly is that build times can be slow in local devel­op­ment, where you real­ly want the speed as you are build­ing out the site CSS.

This arti­cle will lead you through how to opti­mize your Tail­wind CSS build process so that your hot mod­ule replace­ment / live reload will be fast again, and explains what’s going on under the hood.

TL;DR for Shawn is that we can split up our CSS into sep­a­rate­ly import­ed chunks, so that we’re not pro­cess­ing the mas­sive tailwindcss/utilities unless we’re actu­al­ly chang­ing our tailwind.config.js.

So let’s dive in!

Link Framing the Problem

The prob­lem I was hav­ing was that a sim­ple edit of one of my .pcss files would result in a good 10 sec­ond or more delay as the CSS was rebuilt.

Grant­ed, one of the love­ly things about Tail­wind CSS is that you don’t have to write much cus­tom CSS; most­ly you’re putting util­i­ty class­es in your markup.

However, when you do want to add custom CSS, it shouldn’t be this painful

So if I want­ed to do some­thing like change the back­ground col­or of a par­tic­u­lar CSS class, instead of the instant feed­back I was used to from web­pack hot mod­ule replace­ment, I’d be wait­ing a good while for it to recompile.

I wrote up Tail­wind CSS issue #2544, and also cre­at­ed a min­i­mal GitHub repo nys­tu­dio107/­tail­wind-css-per­for­mance to repro­duce it.

But then start­ed spelunk­ing a bit fur­ther to see if I could find a way to mit­i­gate the situation.

I found a tech­nique that sped up my build times immea­sur­ably, and you can use it too.

Link The Problem Setup

My orig­i­nal base Tail­wind CSS set­up looked rough­ly like this:

css
├── app.pcss
├── components
│   ├── global.pcss
│   ├── typography.pcss
│   └── webfonts.pcss
├── pages
│   └── homepage.pcss
└── vendor.pcss

The meat of this is the app.css, which looked like this:

/**
 * app.css
 *
 * The entry point for the css.
 *
 */

/**
 * This injects Tailwind's base styles, which is a combination of
 * Normalize.css and some additional base styles.
 */
 @import 'tailwindcss/base';

/**
 * This injects any component classes registered by plugins.
 *
 */
@import 'tailwindcss/components';

/**
 * Here we add custom component classes; stuff we want loaded
 * *before* the utilities so that the utilities can still
 * override them.
 *
 */
@import './components/global.pcss';
@import './components/typography.pcss';
@import './components/webfonts.pcss';

/**
 * Include styles for individual pages
 *
 */
@import './pages/homepage.pcss';

/**
 * This injects all of Tailwind's utility classes, generated based on your
 * config file.
 *
 */
@import 'tailwindcss/utilities';

/**
 * Include vendor css.
 *
 */
 @import 'vendor.pcss';

This app.pcss file then gets import­ed into my app.ts via:

// Import our CSS
import '../css/app.pcss';

This caus­es web­pack to be aware of it, so the .pcss gets pulled into the build pipeline, and gets processed.

And then my postcss.config.js file looked like this:

module.exports = {
    plugins: [
        require('postcss-import')({
            plugins: [
            ],
            path: ['./node_modules'],
        }),
        require('tailwindcss')('./tailwind.config.js'),
        require('postcss-preset-env')({
            autoprefixer: { },
            features: {
                'nesting-rules': true
            }
        })
    ]
};

And we have a super-stan­dard tailwind.config.js file:

module.exports = {
  theme: {
    // Extend the default Tailwind config here
    extend: {
    },
    // Replace the default Tailwind config here
  },
  corePlugins: {},
  plugins: [],
};

Link The Problem

This set­up above is pret­ty much what is laid out Tail­wind CSS docs sec­tion on Using with Pre­proces­sors: PostC­SS as your pre­proces­sor.

Tail­wind CSS is built using PostC­SS, so it makes sense that if you’re using a build­chain that uses web­pack or Lar­avel Mix (which uses web­pack under the hood) or Gulp that you’d lever­age PostC­SS there, too.

The doc­u­men­ta­tion rec­om­mends that if you’re using the postc­ss-import plu­g­in (which we are, and is a very com­mon­ly used PostC­SS plu­g­in), that you change this:

@tailwind base;
@import "./custom-base-styles.css";

@tailwind components;
@import "./custom-components.css";

@tailwind utilities;
@import "./custom-utilities.css";

To this:

@import "tailwindcss/base";
@import "./custom-base-styles.css";

@import "tailwindcss/components";
@import "./custom-components.css";

@import "tailwindcss/utilities";
@import "./custom-utilities.css";

This is because postcss-import strict­ly adheres to the CSS spec and dis­al­lows @import state­ments any­where except at the very top of a file, so we can’t mix them in togeth­er with our oth­er CSS or @tailwind directives.

And this is where things start to go pear-shaped

When we use @import "tailwindcss/utilities"; instead of @tailwind utilities; all that’s real­ly hap­pen­ing is the tailwindcss/utilities.css file is imported:

@tailwind utilities;

So we’re just side-step­ping the @import loca­tion require­ment in postcss-import by adding a lay­er of indirection.

But we can have a look at node_modules/tailwindcss/dist/ to get a rough idea how large this gen­er­at­ed file is going to be:

❯ ls -alh dist
total 43568
drwxr-xr-x  12 andrew  staff   384B Sep 19 11:34 .
drwxr-xr-x  19 andrew  staff   608B Sep 19 11:35 ..
-rw-r--r--   1 andrew  staff    11K Oct 26  1985 base.css
-rw-r--r--   1 andrew  staff   3.1K Oct 26  1985 base.min.css
-rw-r--r--   1 andrew  staff   1.9K Oct 26  1985 components.css
-rw-r--r--   1 andrew  staff   1.3K Oct 26  1985 components.min.css
-rw-r--r--   1 andrew  staff   5.4M Oct 26  1985 tailwind-experimental.css
-rw-r--r--   1 andrew  staff   4.3M Oct 26  1985 tailwind-experimental.min.css
-rw-r--r--   1 andrew  staff   2.3M Oct 26  1985 tailwind.css
-rw-r--r--   1 andrew  staff   1.9M Oct 26  1985 tailwind.min.css
-rw-r--r--   1 andrew  staff   2.2M Oct 26  1985 utilities.css
-rw-r--r--   1 andrew  staff   1.8M Oct 26  1985 utilities.min.css

We can see that the utilities.css file weighs in at a hefty 2.2M itself; and while this comes out in the wash for pro­duc­tion builds when you’re using PurgeC­SS as rec­om­mend­ed in Tail­wind CSS docs: Con­trol­ling file size, it can be prob­lem­at­ic for local development.

So but why is this a prob­lem? If we’re just @importing the file, why would this be so slow?

The rea­son twofold:

  1. Although Tail­wind CSS has opti­miza­tions in place to mit­i­gate it, there’s still a ton of CSS gen­er­a­tion that has to hap­pen each time a change is made in any of your .pcss files. We lumped them all in togeth­er, so they all get rebuild together.
  2. The file is just plain big. The postcss-import plu­g­in seems to parse any files you @import, look­ing for oth­er @import state­ments in that import­ed file. It also takes time for web­pack to wrap this large file in a JS wrap­per, and for the style-loader plu­g­in to inject it into the DOM.

And our result­ing utilities.css file has by default over 100,000 lines of CSS gen­er­at­ed & parsed through:

❯ wc -l utilities.css
  102503 utilities.css

So that’s not good.

Link The Solution

So what can we do? It’s inher­ent to Tail­wind CSS that it’s going to cre­ate a ton of util­i­ty CSS for you, and that gen­er­a­tion can only be opti­mized so much.

I start­ed think­ing of var­i­ous caching mech­a­nism that could be added to Tail­wind CSS, but I real­ized the right solu­tion was to just lever­age the platform.

I remem­bered an old Comp Sci maxim:

The fastest code is the code you never have to execute

We’re already using web­pack, which adroit­ly han­dles tons of imports of var­i­ous kinds through a vari­ety of load­ers… why not just break our .pcss up into chunks, and let web­pack sort it out?

Link The Solution Setup

So that’s exact­ly what I did in the solu­tion branch of nys­tu­dio107/­tail­wind-css-per­for­mance.

Now our CSS direc­to­ry looks like this:

css
├── app-base.pcss
├── app-components.pcss
├── app-utilities.pcss
├── components
│   ├── global.pcss
│   ├── typography.pcss
│   └── webfonts.pcss
├── pages
│   └── homepage.pcss
└── vendor.pcss

Our app.pcss file has been chopped up into 3 sep­a­rate .pcss files that cor­re­spond with Tail­wind’s base, components, and utilities methodology:

/**
 * This injects Tailwind's base styles, which is a combination of
 * Normalize.css and some additional base styles.
 */
@import 'tailwindcss/base';

/**
 * Here we add custom base styles, applied after the tailwind-base
 * classes
 *
 */
/**
 * This injects any component classes registered by plugins.
 *
 */
@import 'tailwindcss/components';

/**
 * Here we add custom component classes; stuff we want loaded
 * *before* the utilities so that the utilities can still
 * override them.
 *
 */
@import './components/global.pcss';
@import './components/typography.pcss';
@import './components/webfonts.pcss';

/**
 * Include styles for individual pages
 *
 */
@import './pages/homepage.pcss';
/**
 * This injects all of Tailwind's utility classes, generated based on your
 * config file.
 *
 */
@import 'tailwindcss/utilities';

/**
 * Include vendor css.
 *
 */
 @import 'vendor.pcss';

And then these .pcss files then get import­ed into our app.ts via:

// Import our CSS
import '../css/app-base.pcss';
import '../css/app-components.pcss';
import '../css/app-utilities.pcss';

Noth­ing else was changed in our con­fig or setup.

This allows web­pack to han­dle each chunk of import­ed .pcss sep­a­rate­ly, so the Tail­wind-gen­er­at­ed CSS (and impor­tant­ly the huge utilities.css) only needs to be rebuilt if some­thing that affects it (like the tailwind.config.js) is changed.

Leverage the platform

All of the CSS is still import­ed in the right order, and they all also build into one com­bined CSS file for a pro­duc­tion build.

Changes to any of the .pcss files that we write are rebuilt sep­a­rate­ly & instantly.

💥

The only down­side here is since we don’t have one glob­al CSS file, in Tail­wind 2.0 @apply won’t work across imports. So you can’t @apply a class in, say, some file that app-components.pcss @imports from any files that app-utilities.pcss @imports.

In Tail­wind 1.x, glob­al @apply works great with the tech­nique dis­cussed in this arti­cle. As of Tail­wind 2.0.3 in which a fix was applied, Tail­wind 2.x works great as well!

Link Benchmarking Problem vs. Solution

I did a few infor­mal bench­marks while test­ing all of this out. I used a 2019 Mac­Book Pro with 64gb RAM.

For the test, all I did was change the background-color: yellow; to background-color: blue; in the css/components/global.pcss file.

When we do a rebuild using the Prob­lem Set­up”, the webpack-dev-server [WDS] out­put in the browser’s Devel­op­er JavaScript Con­sole looks like this:

[WDS] App updated. Recompiling...
[WDS] App hot update...
[HMR] Checking for updates on the server...
[HMR] Updated modules:
[HMR]  - ../src/css/app.pcss
[HMR] App is up to date.

…and it took 11.74s to do this HMR rebuild.

Notice that it rebuild the entire app.pcss here.

When we do a rebuild using the Solu­tion Set­up”, the webpack-dev-server [WDS] out­put in the browser’s Devel­op­er JavaScript Con­sole looks like this:

[WDS] App updated. Recompiling...
[WDS] App hot update...
[HMR] Checking for updates on the server...
[HMR] Updated modules:
[HMR]  - ../src/css/app-components.pcss
[HMR] App is up to date.

…and it only took 0.52s to do this HMR rebuild.

Notice that it rebuilt only the app-components.pcss here, and deliv­ered just the diff of it to the brows­er in a high­ly effi­cient manner.

So with our changes, it’s now 22.5x faster

This is a nice gain, and makes the devel­op­ment expe­ri­ence much more enjoyable!

Hap­py sail­ing! ≈ 🚀

Link Handling SourceMaps Right

While I was pro­fil­ing Tail­wind CSS build times, I found that there was­n’t much dif­fer­ence between:

  1. The default rec­om­mend­ed set­up using @import 'tailwindcss/utilities'
  2. Import­ing just a mas­sive CSS file, in this case the bun­dled, built utilities.css via @import 'tailwindcss/dist/utilities.css
  3. Direct­ly inlin­ing utilities.css into the app.pcss file (just to see if the postcss-import plu­g­in added overhead)

TL;DR: we can cut our build process time in half by not gen­er­at­ing CSS sourcemaps, and by set­ting devtool: 'eval-cheap-module-source-map' in the webpack.dev.js con­fig.

The sep­a­rat­ed imports described in this arti­cle are still use­ful, because it get us down to < 0.5s for the HMR reload times, but the 3.2s HMR times I’m see­ing now are at least usable if you do need things like glob­al­ly avail­able @apply.

Some tim­ings:

webpack_1  | Version: webpack 4.44.2
webpack_1  | Time: 7260ms
webpack_1  | Built at: 10/14/2020 1:13:05 PM
webpack_1  |                                  Asset       Size  Chunks                               Chunk Names
webpack_1  | app.eaa21190e6318cfca3af.hot-update.js   8.77 MiB     app  [emitted] [immutable] [hmr]  app
webpack_1  |   eaa21190e6318cfca3af.hot-update.json   45 bytes          [emitted] [immutable] [hmr]
webpack_1  |                              js/app.js   10.8 MiB     app  [emitted]                    app
webpack_1  |                          manifest.json  179 bytes          [emitted]
webpack_1  |  + 2 hidden assets

webpack_1  | Version: webpack 4.44.2
webpack_1  | Time: 4103ms
webpack_1  | Built at: 10/14/2020 1:30:14 PM
webpack_1  |                                  Asset       Size  Chunks                               Chunk Names
webpack_1  |   420f72e6cc40facb1510.hot-update.json   45 bytes          [emitted] [immutable] [hmr]
webpack_1  | app.420f72e6cc40facb1510.hot-update.js   6.53 MiB     app  [emitted] [immutable] [hmr]  app
webpack_1  |                              js/app.js   8.53 MiB     app  [emitted]                    app
webpack_1  |                          manifest.json  179 bytes          [emitted]
webpack_1  |  + 2 hidden assets

webpack_1  | Version: webpack 4.44.2
webpack_1  | Time: 3296ms
webpack_1  | Built at: 10/14/2020 1:59:52 PM
webpack_1  |                                  Asset       Size  Chunks                               Chunk Names
webpack_1  |   7059df1a7293e89c81d1.hot-update.json   45 bytes          [emitted] [immutable] [hmr]
webpack_1  | app.7059df1a7293e89c81d1.hot-update.js   5.99 MiB     app  [emitted] [immutable] [hmr]  app
webpack_1  |                              js/app.js   7.98 MiB     app  [emitted]                    app
webpack_1  |                          manifest.json  179 bytes          [emitted]
webpack_1  |  + 2 hidden assets