
# Cache busting: why your ?v= probably does nothing

Our navigation rendered with no styles at all in production for part of an afternoon. The HTML was right, the CSS file on the server was right, and the deploy had succeeded. It was misdiagnosed twice before anyone looked at the right thing.

`app.css` shipped with no `?v=` on it at all. Cloudflare had it cached for four hours, browsers for longer. The release renamed a batch of CSS classes, so new HTML arrived at a browser still holding the previous build's stylesheet: every class in the markup matched nothing.

The tell that this was a cache and not a deploy is the same thing that made it hard to see: a hard reload fixed it, and anyone opening the site for the first time saw it fine. A cold cache is indistinguishable from a working deploy, so nobody testing on a clean profile ever reproduced it.

`app.js` did have a `?v=`, and that was worse than not having one. It interpolated the version out of `package.json`:

```html
<script src="/js/app.js?v=<%= appVersion %>" defer></script>
```

That number does not move between releases; we ship far more often than we bump it. The JS carried a token that looked like cache busting to everyone reading the template and busted nothing. Same bug as the CSS, one release away from firing, with a decoy sitting in front of it.

## Hash the file

A cache-busting token has one job: change when the asset changes, and not otherwise. The only value that does that is derived from the file's own bytes.

```js
import { readFileSync } from 'node:fs';
import { createHash } from 'node:crypto';

function assetVersion(relativePath) {
  const buf = readFileSync(join(__dirname, 'public', relativePath));
  return createHash('sha1').update(buf).digest('hex').slice(0, 8);
}

const cssVersion = assetVersion('css/app.css');
```

Read once at startup: these are build outputs and cannot change under a running process. Eight hex characters is plenty, and SHA-1 is fine here — you are avoiding an accidental collision between two versions of your own file, not defending against an attacker who chooses the contents.

Then the template stops lying:

```html
<link rel="stylesheet" href="/css/app.css?v=<%= cssVersion %>">
```

The three tokens people reach for instead:

**A version number** (`?v=1.4.2`) changes when a human remembers to change it, which is not the same as when the file changes. That was our `app.js`.

**A timestamp or build id** does change on every deploy, so it never serves stale — but it also changes when nothing changed. Every deploy throws away every visitor's cached CSS and every CDN edge's copy, whether or not a single byte moved, so a release that touched only the backend still costs every user a re-download.

**A random value** — `?v=<%= Math.random() %>`, and people do write it — is a new URL on every page view. That does not bust the cache, it switches caching off permanently for everyone.

Only the content hash gets both halves: never stale, and never re-downloaded when nothing changed.

## Query string or filename

`app.a1b2c3d4.css` makes the same promise with the hash in the filename instead of the query string. It needs a build step that rewrites every reference, which is why bundler-based projects usually have it and server-rendered ones usually don't.

The old argument for filenames is that some caches ignore the query string when computing a cache key, which turns `?v=` into decoration. That is still a setting you can switch on by accident — Cloudflare's caching level has an "ignore query string" option — and if it is on, every `?v=` on your site silently stops working. Check before you rely on it.

## `immutable` is a promise about the URL, not the file

`Cache-Control: public, max-age=31536000, immutable` tells the browser not to revalidate, not even when the user hits reload. That is only true if the URL is a content address, and once you say it that way the rule writes itself:

```js
if (/\.(css|js)$/.test(name)) {
  const versioned = /[?&]v=/.test(url) || filePath.includes('/vendor/');
  res.setHeader('Cache-Control', versioned
    ? 'public, max-age=31536000, immutable'
    : 'public, max-age=3600');
}
```

The same file requested without the token gets an hour. The token is the promise, and nothing should be cached for a year on a promise nobody made.

Check that your static middleware is not overwriting this. `@fastify/static` sends `max-age=0` by default; set headers without disabling its own `cacheControl` and it rebuilds the header from that default and quietly overwrites yours. The header is still there, still looks deliberate, and is wrong.

Fonts we mark immutable by name. A subset build gets replaced by renaming rather than by editing, so the filename is already the content address. Vendored libraries the same way: `alpine-3.14.8.min.js` carries its version in the name and nobody edits it in place.

Images get a week, not a year. They are unversioned, and a logo or a favicon does get replaced in place — long enough to be worth having, short enough to fix a mistake.

## The file that must stay short-lived

We serve a badge script that runs inside other people's pages. We control neither their HTML nor the URL they embed. A year of `immutable` there would mean a bug we cannot reach for a year: no way to invalidate, no way to ask every site carrying the tag to change it. It gets an hour, and that is right even though the file almost never changes.

Anything on a URL you cannot version works this way. Its cache lifetime is set by how fast you need to be able to fix it, not by how often it changes.

## Verify it

Does the token in the HTML match the file on disk?

```bash
curl -s https://example.com/ | grep -o 'app\.css?v=[a-f0-9]*'
shasum -a 1 public/css/app.css | cut -c1-8
```

Same string, or your HTML points at something it does not describe. If the token holds still across two deploys that both changed the CSS, you have our `app.js`.

Then ask the asset what it replies with:

```bash
curl -sI 'https://example.com/css/app.css?v=a1b2c3d4' | grep -i 'cache-control\|last-modified\|cf-cache-status'
```

`last-modified` is what catches a stale edge. If it predates your last deploy of that file, something between your server and the browser is still holding the old copy — and the token in your HTML is what decides whether that matters.

[Check your site](/) · [AI-readiness checklist](/tools/ai-readiness-checklist) · [What we measure](/about)
