CSS Cache Busting in Eleventy

Posted on: 20 April 2022

I’m pretty new to 11ty, and I’m figuring things out as I go. One of those things recently was cache busting my CSS. I’m by no means an expert in front-end development, but I figured it couldn’t be that hard to add a fingerprint hash to my CSS files to ensure changes are always pulled in by the browser when viewing my site.

Why cache bust?

To serve up a quick website, you need to very mindful of caching. If you can, you want to tell the browser to cache a particular URL for as long as possible. That way repeated round-trip to the server to ask for the file’s contents can be avoided. Also, as a rule, URLs shouldn’t change. In a sense, I was breaking both of these guidelines with my CSS.

Firstly, I was telling the browser to re-evaluate the contents of my CSS each time the URL is accessed. And secondly, the contents of that CSS file was changing each time I deployed any stylesheet changes. And even though my caching strategy was very loose, the browser was still hanging onto stale versions of my CSS file after I deployed a change, which is a problem.

Enter, the fingerprint

A fingerprint is a hash, unique to the contents of the file. Honestly, I have no idea how this works in practice, but it’s a very useful thing to be able to generate when it comes to caching.

Instead of always serving up style.css to the browser, and telling it “hey, this might have changes in it, you should go check”, it’s a much smarter idea to just serve up a new URL each time your CSS changes. That’s where the fingerprint comes in.

By tacking on the CSS file’s unique fingerprint to the end of the file’s URL, we make a new, unique URL each time the CSS file’s contents change. Neat. But how do we accomplish this with Eleventy?

Parcel, 11ty, and some custom code in between

I’m using Tailwind on my project, a really cool utility-first approach to CSS. The big difference between using Tailwind and defining your own styles in a CSS file, is that changes to your templates trigger changes to your (generated) CSS file.

This works using Tailwind’s “Just in time” engine. Each time a watched file changes, Tailwind will scan the template for one of its known utility classes, and if it’s found, adds the class definition to your CSS file. What this means in practice is any fingerprinting we do has to be on the generated Tailwind CSS, not your source CSS.

So, to make this work, I needed to do the following:

  1. Process CSS
  2. Calculate the generated CSS fingerprint
  3. Save the processed CSS file with the fingerprinted filename
  4. Tell Eleventy how to access the fingerprinted CSS file

I'm using using the NPM package npm-run-all to run my Eleventy and Tailwind scripts at the same time, as it didn’t matter which was processed first. Parcel processed my Tailwind CSS and saved it into Eleventy’s output directory using the following command.

parcel watch content/css/*.css --dist-dir _site/css

And I was building my Eleventy site using the following:

eleventy --input=content

I defined both these commands as NPM scripts (prod:tailwind and prod:eleventy respectively) and ran them in parallel using npm-run-all:

npm-run-all --parallel prod:tailwind prod:eleventy

For cache-busting purposes, however, we need to do each step sequentially to ensure the process occurs in the correct order. More on this later.

Calculating the fingerprint

My first bash at this is a simple Javascript file I saved to scripts/hash.js in my repository. It looked like this:

const fs = require('fs');
const md5 = require('md5');

const cssFile = '_site/css/tailwind.css';
const fileContents = fs.readFileSync(cssFile);
const hashedFilename = `css/styles-${md5(fileContents)}.css`;

fs.writeFileSync(`content/_data/css.json`, `{"path": "/${hashedFilename}"}`);

fs.renameSync(cssFile, `_site/${hashedFilename}`);

It takes the output file of the CSS from running the parcel command above, reads its contents, then works out the MD5 hash of it. We use this to build a new filename, comprised of the fingerprint hash.

I’ll talk about the fs.writeFileSync command shortly, but the final step in the process is to rename the CSS file Parcel generated to the fingerprinted filename.

Telling 11ty about the fingerprinted CSS

We’ve got the fingerprinted URL taken care of, but now we need to tell 11ty how it can access it, so we’re able to link to it in the template. Our current template looks like this:

<link rel="stylesheet" href="/css/tailwind.css" />

But we need it to look like this:

<link rel="stylesheet" href="/css/<fingerprinted-file>.css" />

The solution: we can utilise 11ty’s excellent global data files system to generate a JSON file at the point of hashing, which will tell 11ty about the fingerprinted CSS filename. This turns out to be a simply one-liner.

In short: create a JSON file in the _data directory called css.json, and have this contain a JSON string mapping a “path” key to the fingerprinted CSS filename value. Through the magic of 11ty, when the site is built, we can then reference this value in our template using the variable css.path, like so:

<link rel="stylesheet" href="{% if css %}{{ css.path }}{% else %}/css/tailwind.css{% endif %}" />

Note: The conditional is needed because we only want to do this fingerprinting malarky in production. In development, the browser won’t cache the CSS file, so we can just reference the generated CSS file straight out of Parcel.

Tidying up

On my site, I’m only using a single CSS file, tailwind.css. However, it’s conceivable down the line that I may have multiple frontend assets that I want to fingerprint. With that in mind, I adapted hash.js to loop over the directory and fingerprint all CSS files contained within:

const fs = require('fs');
const path = require('path');
const md5 = require('md5');
const glob = require('glob');

const hashedPaths = Object.fromEntries(glob.sync('_site/css/!(*-*).css').map(file => {
const filename = path.basename(file, '.css');
const fileContents = fs.readFileSync(file);
const hashedFilename = `css/${filename}-${md5(fileContents)}.css`;
fs.renameSync(file, `_site/${hashedFilename}`);
return [filename, `/${hashedFilename}`];
}));

fs.writeFileSync(`content/_data/css.json`, JSON.stringify(hashedPaths));

Here, we use glob to read all the CSS files within the _site/css directory, fingerprint them, and rename the original. We use a pattern match on the filename (!(*-*).css) to ensure we don’t reprocess already fingerprinted files (although this is unlikely to happen in production when builds are done afresh each time).

Finally, we build up a hash of the fingerprinted filenames (using the original filename, minus the extension, as the key), convert it to JSON, then save the contents to the css.json global data file.

We can then refer to specific CSS files in the template like so:

<link rel="stylesheet" href="{% if css %}{{ css.tailwind }}{% else %}/css/tailwind.css{% endif %}" />

If I add client-side Javascript to my site, I’ll likely adapt hash.js again to process JS files as well as CSS, and create a more generic assets.json data file.

Scripting it

As I need the hashing script to run after Parcel, but before 11ty, I can no longer run the various build scripts in parallel. First, however, we need to define a script to run hash.js:

"scripts": {
..
"hash": "node ./scripts/hash.js",
}

Then, in the build command, we process Tailwind, then hash, then Eleventy sequentially:

"scripts": {
..
"hash": "node ./scripts/hash.js",
"build": "npm-run-all prod:tailwind hash prod:eleventy",
"prod:eleventy": "eleventy --input=content",
"prod:tailwind": "parcel build content/css/* --dist-dir _site/css"
}

To sum up

It took me a while to get my head around this method, and it’s very fair to say I stood on the shoulders of giants to get here. Particularly helpful was Bryce Wray’s excellent article Cache Busting in Eleventy, take two. I took a slightly different approach in the end, but Bryce’s article was essential reading to get me into the mindset.