Adding a Cache Buster to your esbuild Pipeline
You've got a great JS app, your users love it. But every time you want to deploy new code, you struggle with the same support messages from your users:
- "I don't see it yet?"
- "It still doesn't work."
- "Do you know what you're doing?"
- "I don't think you fixed it."
And of course, you have the same sentence being said to your screen that developers around the world over have said to theirs, repeatedly:
"But it looks great on my screen!"
This is all happening because the previous assets for your site, specifically your CSS and JS files, are cached in your user's browser. And since we can't ship your laptop around to every user until their cache expires, we need a way to allow users to get the updated version of the code as quickly as possible. Enter cache busting.
What is cache busting?
Back when the web was young and bandwidth was not the firehose that most of us have available today, browser companies were very interested in ways to make the web more performant. This isn't to say that it isn't a concern now, but the load time difference between a 250kb file and a 125kb file is extremely nominal on modern internet, whereas in 2002, it was a Very Big Dealβ’ over dial-up. The logic was that there are assets that are going to remain static across page loads, so if the browser retains a copy of them locally, it's faster to load them from the hard drive than it is to request and transfer them over the internet.
This also helped server performance because it was less incoming requests coming into the server which had to be handled individually. The upside is that after the first page load, it dramatically improved the browser's ability to re-load a page quickly.
The downside, of course, is that if assets such as HTML, CSS, or JS changed before a cached version expired, then the user would not see the new code and would have a less-than-ideal experience.
Cache busting solved this problem by making sure that there was a unique identifier added to every filename request to force the browser to load a new copy of the file each time.
What would have been https://somesite.com/js/app.js
before would now be something like https://somesite.com/js/app.1693597811135.js
or https://somesite.com/js/app.js?version=1693597811135
. In either case, the added value to the filename or URI forces the browser to fetch a new copy of the file, as it doesn't match what is stored locally.
While both of these approaches are valid, we are going to use the first example, where we modify the filename of the file. This is because some site speed tools and CDNs do not recommend the use of querystrings for cache busting.
Doing this in esbuild
A lot of developers are moving to using esbuild-based tools, following the demise of Create React App (CRA). CRA was webpack-based, and cache busting was something that it handled very easily. With the advent of tools like Vite, many in the industry are shifting over to esbuild as the bundler/build tool of choice.
Yes, I know that it is not the only good bundler out there, but it is A good one. The relative merits of esbuild vs <insert your bundler/build tool of choice>
can be done in another article.
The rough outline of the steps that we need to do are as follows:
1) Create a "templatized" version of our index.html
2) Generate a unix millisecond timestamp during our build process
3) Substitute that value into our index.html
4) Rewrite our index.html
5) Do our build in esbuild
6) Profit
Creating a template
To make this happen, we want to make a copy of our index.html
file somewhere that we can logically access it. As I have a build script in the top-level directory of my project, which targets building into a public
folder, I added a templates
folder to my project.
In that directory, I copied my existing index.html
and named it template.html
. It looks like this:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="/favicon.ico" />
<link rel="stylesheet" href="/css/index.css" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#e36858" />
<meta name="description" content="My app is super cool."
/>
<link rel="manifest" href="/manifest.json" />
<title>My Super Cool App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script src="/js/app.%%VERSION%%.js"></script>
</body>
</html>
As you can see, I have added a "template"-esque %%VERSION%%
tag to the app.js
filename. This will get replaced during the build process with the current Unix timestamp. If your CSS is being compiled (using Sass or some other pre-processor) as part of your esbuild pipeline, then I would encourage you to do something similar with the index.css
filename.
Generating Unix timestamp
There are a lot of good date/time libraries out there (RIP Moment). And all of them have the ability to generate a unix timestamp, with milliseconds. Whether you are using Luxon, date-fns, dayjs, or the JS Date
object (gasp), you'll be performing the same kind of operation. Similar to which is the best bundler, deciding on which date library we "should" use is a separate article. For the purposes of this demo, I'll use dayjs.
The code for this is very straightforward:
dayjs().valueOf();
This will give me a unix timestamp in milliseconds, which I can use in my build process.
Using the timestamp
So our logic here for implementing this is going to be as follows:
- Generate the timestamp
- Load the
template.html
file - Replace the template text with the timestamp
- Write out the new content as
index.html
As we are running the build script using node, this means we're going to want to use the fs
module. Fortunately, this process becomes very straightforward if we use synchronous tasks from the fs
module:
const version = dayjs().valueOf();
console.log(`Adding cache buster ${version} to index.html`);
const index = fs.readFileSync('./templates/template.html', {encoding: 'utf-8'});
const newIndex = index.replaceAll('%%VERSION%%', version);
fs.writeFileSync('./public/index.html', newIndex);
As you can see from the few lines of code above, we are following our outline. I've added a console.log
for output just so that we have the cache buster value to refer to and confirm that it's being served up properly. Even though my example only has one instance of the %%VERSION%%
tag in it, it's best to leave it as .replaceAll()
in case we add others(CSS as discussed above, for example). That's a bug that future-Us won't have to fix.
Adding this to esbuild
The esbuild tool gives us the ability to do callbacks on various parts of the build pipeline through the use of plugins. The final piece of the puzzle for this solution is to create our own plugin and implement the onStart
callback in it. If you'd like to read more, you can check out the esbuild plugin docs.
Here is my plugin, which I put above my esbuild.build()
section of my build script:
//We want this outside the scope of the callback, as we will
//need it later in the build process
const version = dayjs().valueOf();
const cacheBusterPlugin = {
name: 'cacheBusterPlugin',
setup(build) {
build.onStart(() => {
console.log(`Adding cache buster ${version} to index.html`);
const index = fs.readFileSync('./template.html', {encoding: 'utf-8'});
const newIndex = index.replaceAll('%%VERSION%%', version);
fs.writeFileSync('./public/index.html', newIndex);
})
},
};
You'll notice that our code from above is now the body of our build.onStart
callback function. Now we need to add this plugin to our esbuild pipeline, and that just means registering it as a plugin, and updating our outfile name:
esbuild.build({
entryPoints: ["./src/index.js"],
outfile: `./public/js/app.${version}.js`,
minify: true,
bundle: true,
loader: {
".js": "jsx",
},
plugins: [cacheBusterPlugin],
}).catch(() => process.exit(1));
Now when you run your build, your cacheBusterPlugin
will automatically run on the start of the build, load the template, generate the timestamp, write it into the content, and save your index.html
file.
Happy building!
Top comments (0)