Speeding Up Tailwind CSS Builds
Learn how to optimize your Tailwind CSS PostCSS build times to make local development with Hot Module Replacement or Live Reload orders of magnitude faster!
Andrew Welch / nystudio107
Tailwind CSS is a utility-first CSS framework that we’ve been using for several years, and it’s been pretty fantastic. We first talked about it on the Tailwind CSS utility-first CSS with Adam Wathan episode of devMode.fm way back in 2017!
We use it in the base of every project we do, as part of a webpack build process described in the An Annotated webpack 4 Config for Frontend Web Development article.
One issue that we’ve run into recently is that build times can be slow in local development, where you really want the speed as you are building out the site CSS.
This article will lead you through how to optimize your Tailwind CSS build process so that your hot module replacement / live reload will be fast again, and explains what’s going on under the hood.
So let’s dive in!
Framing the Problem
The problem I was having was that a simple edit of one of my .pcss files would result in a good 10 second or more delay as the CSS was rebuilt.
Granted, one of the lovely things about Tailwind CSS is that you don’t have to write much custom CSS; mostly you’re putting utility classes in your markup.
So if I wanted to do something like change the background color of a particular CSS class, instead of the instant feedback I was used to from webpack hot module replacement, I’d be waiting a good while for it to recompile.
I wrote up Tailwind CSS issue #2544, and also created a minimal GitHub repo nystudio107/tailwind-css-performance to reproduce it.
But then started spelunking a bit further to see if I could find a way to mitigate the situation.
I found a technique that sped up my build times immeasurably, and you can use it too.
The Problem Setup
My original base Tailwind CSS setup looked roughly 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';
/**
* This injects all of Tailwind's utility classes, generated based on your
* config file.
*
*/
@import 'tailwindcss/utilities';
/**
* Include styles for individual pages
*
*/
@import './pages/homepage.pcss';
/**
* Include vendor css.
*
*/
@import 'vendor.pcss';
This app.pcss file then gets imported into my app.ts via:
// Import our CSS
import '../css/app.pcss';
This causes webpack 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-standard tailwind.config.js file:
module.exports = {
theme: {
// Extend the default Tailwind config here
extend: {
},
// Replace the default Tailwind config here
},
corePlugins: {},
plugins: [],
};
The Problem
This setup above is pretty much what is laid out Tailwind CSS docs section on Using with Preprocessors: PostCSS as your preprocessor.
Tailwind CSS is built using PostCSS, so it makes sense that if you’re using a buildchain that uses webpack or Laravel Mix (which uses webpack under the hood) or Gulp that you’d leverage PostCSS there, too.
The documentation recommends that if you’re using the postcss-import plugin (which we are, and is a very commonly used PostCSS plugin), 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 strictly adheres to the CSS spec and disallows @import statements anywhere except at the very top of a file, so we can’t mix them in together with our other CSS or @tailwind directives.
When we use @import "tailwindcss/utilities"; instead of @tailwind utilities; all that’s really happening is the tailwindcss/utilities.css file is imported:
@tailwind utilities;
So we’re just side-stepping the @import location requirement in postcss-import by adding a layer of indirection.
But we can have a look at node_modules/tailwindcss/dist/ to get a rough idea how large this generated 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
(incidentally, if you look closely, you’ll also have learned Adam Wathan’s birthday)
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 production builds when you’re using PurgeCSS as recommended in Tailwind CSS docs: Controlling file size, it can be problematic for local development.
So but why is this a problem? If we’re just @import’ing the file, why would this be so slow?
The reason twofold:
- Although Tailwind CSS has optimizations in placeto mitigate it, there’s still a ton of CSS generation that has to happen each time a change is made in any of your .pcss files. We lumped them all in together, so they all get rebuild together.
- The postcss-import plugin actually parses any files you @import, looking for other @import statements in that imported file, and it does this on the CSS that the @tailwind directive generates, too.
And our resulting utilities.css file has by default over 100,000 lines of CSS generated & parsed through:
❯ wc -l utilities.css
102503 utilities.css
So that’s not good.
The Solution
So what can we do? It’s inherent to Tailwind CSS that it’s going to create a ton of utility CSS for you, and that generation can only be optimized so much.
I started thinking of various caching mechanism that could be added to Tailwind CSS, but I realized the right solution was to just leverage the platform.
I remembered an old Comp Sci maxim:
We’re already using webpack, which adroitly handles tons of imports of various kinds through a variety of loaders… why not just break our .pcss up into chunks, and let webpack sort it out?
The Solution Setup
So that’s exactly what I did in the solution branch of nystudio107/tailwind-css-performance.
Now our CSS directory looks like this:
css
├── app-base.pcss
├── app-components.pcss
├── app-utilities.pcss
├── components
│ ├── global.pcss
│ ├── typography.pcss
│ └── webfonts.pcss
├── pages
│ └── homepage.pcss
├── tailwind-base.pcss
├── tailwind-components.pcss
├── tailwind-utilities.pcss
└── vendor.pcss
Our app.pcss file has been chopped up into 6 separate .pcss files that correspond with Tailwind’s base, components, and utilities methodology:
/**
* This injects Tailwind's base styles, which is a combination of
* Normalize.css and some additional base styles.
*/
@tailwind base;
/**
* Here we add custom base styles, applied after the tailwind-base
* classes
*
*/
/**
* This injects any component classes registered by plugins.
*
*/
@tailwind 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';
/**
* This injects all of Tailwind's utility classes, generated based on your
* config file.
*
*/
@tailwind utilities;
/**
* Include styles for individual pages
*
*/
@import './pages/homepage.pcss';
/**
* Include vendor css.
*
*/
@import 'vendor.pcss';
And then these .pcss files then get imported into our app.ts via:
// Import our CSS
import '../css/tailwind-base.pcss';
import '../css/app-base.pcss';
import '../css/tailwind-components.pcss';
import '../css/app-components.pcss';
import '../css/tailwind-utilities.pcss';
import '../css/app-utilities.pcss';
Nothing else was changed in our config or setup.
This allows webpack to handle each chunk of imported .pcss separately, so the Tailwind-generated CSS (and importantly the huge utilities.css) only needs to be rebuilt if something that affects it (like the tailwind.config.js) is changed.
Changes to any of the .pcss files that we write are rebuilt separately & instantly.
💥
Benchmarking Problem vs. Solution
I did a few informal benchmarks while testing all of this out. I used a 2019 MacBook 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 “Problem Setup”, the webpack-dev-server [WDS] output in the browser’s Developer JavaScript Console 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 recompile on my 2019 MacBook Pro.
Notice that it rebuild the entire app.pcss here.
When we do a rebuild using the “Solution Setup”, the webpack-dev-server [WDS] output in the browser’s Developer JavaScript Console 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 delivered just the diff of it to the browser in a highly efficient manner.
This is a nice gain, and makes the development experience much more enjoyable!
Happy sailing! ≈ 🚀
Further Reading
If you want to be notified about new articles, follow nystudio107 on Twitter.
Copyright ©2020 nystudio107. Designed by nystudio107
Top comments (1)
Developing effective caching strategies to speed up Tailwind CSS builds poses a challenge. Identifying the right balance between caching for efficiency and refreshing when necessary without introducing stale styles requires thoughtful planning to optimize build times.