Andrew Welch
Published , updated · 5 min read · RSS Feed
Please consider 🎗 sponsoring me 🎗 to keep writing articles like this.
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!
Update: Tailwind Labs has released the TailwindCSS JIT that provides excellent performance gains. It’s still an alpha version at the time of this writing, but it’s worth a look instead of implementing the “CSS splitting” described in this article.
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.
TL;DR for Shawn is that we can split up our CSS into separately imported chunks, so that we’re not processing the massive tailwindcss/utilities unless we’re actually changing our tailwind.config.js.
So let’s dive in!
Link 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.
However, when you do want to add custom CSS, it shouldn’t be this painful
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.
Link 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';
/**
* 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 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: [],
};
Link 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.
And this is where things start to go pear-shaped
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
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 place to 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 file is just plain big. The postcss-import plugin seems to parse any files you @import, looking for other @import statements in that imported file. It also takes time for webpack to wrap this large file in a JS wrapper, and for the style-loader plugin to inject it into the DOM.
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.
Link 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:
The fastest code is the code you never have to execute
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?
Link 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
└── vendor.pcss
Our app.pcss file has been chopped up into 3 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.
*/
@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 imported into our app.ts via:
// Import our CSS
import '../css/app-base.pcss';
import '../css/app-components.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.
Leverage the platform
All of the CSS is still imported in the right order, and they all also build into one combined CSS file for a production build.
Changes to any of the .pcss files that we write are rebuilt separately & instantly.
💥
The only downside here is since we don’t have one global CSS file, in Tailwind 2.0 @apply won’t work across imports. So you can’t @apply a class in, say, some file that app-components.pcss @import’s from any files that app-utilities.pcss @import’s.
In Tailwind 1.x, global @apply works great with the technique discussed in this article. As of Tailwind 2.0.3 in which a fix was applied, Tailwind 2.x works great as well!
Link 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 HMR rebuild.
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.
So with our changes, it’s now 22.5x faster
This is a nice gain, and makes the development experience much more enjoyable!
Happy sailing! ≈ 🚀
Link Handling SourceMaps Right
While I was profiling Tailwind CSS build times, I found that there wasn’t much difference between:
- The default recommended setup using @import 'tailwindcss/utilities'
- Importing just a massive CSS file, in this case the bundled, built utilities.css via @import 'tailwindcss/dist/utilities.css
- Directly inlining utilities.css into the app.pcss file (just to see if the postcss-import plugin added overhead)
TL;DR: we can cut our build process time in half by not generating CSS sourcemaps, and by setting devtool: 'eval-cheap-module-source-map' in the webpack.dev.js config.
The separated imports described in this article are still useful, because it get us down to < 0.5s for the HMR reload times, but the 3.2s HMR times I’m seeing now are at least usable if you do need things like globally available @apply.
Some timings:
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