Dev.to WebDev 🛠 Dev 👁 0 📖 4 min read

Cache-Busting Static Assets with ?v=: So Visitors Actually Get the Fix

The most embarrassing bug report I ever got went like this: I fixed a bug, deployed it, tested it myself, and the visitor still saw the broken version. The new file was on the server. My own browser showed the fix. Then

The most embarrassing bug report I ever got went like this: I fixed a bug, deployed it, tested it myself, and the visitor still saw the broken version. The new file was on the server. My own browser showed the fix. Then I talked the visitor through clearing their cache, and suddenly everything worked.

The bug was never in my code. It was in the browser cache, and it taught me the cheapest lesson in web performance: browsers remember your files far longer than you expect, and you have to give them a reason to forget.

Why the browser holds on so tightly

When a browser downloads style.css or app.js, it keeps a copy. The next time it needs that file, it checks its cache first. If the server sent a far-future Cache-Control header like max-age=31536000, the browser may not even ask the server whether the file changed. It just serves the old copy, silently, for a year.

That is exactly what you want for performance. A returning visitor should not re-download files that have not changed. The problem starts the day you do change one. The URL is identical, the filename is identical, and the browser has no way of knowing the contents are different. Your fix is live on the server and invisible to everyone who visited before.

The ?v= trick

The fix is almost embarrassingly simple: change the URL when the file changes. Browsers cache by URL, so a different URL means a fresh download. I append a version query string to every static asset:

<link rel="stylesheet" href="/css/style.css?v=20261106">
<script src="/js/app.js?v=20261106"></script>

The ?v=20261106 part means nothing to the server. It serves the same file and ignores the query string. But to the browser, /css/style.css?v=20261106 and /css/style.css?v=20261105 are two different resources. The day I ship a change, I bump the number, and every visitor fetches the new file on their next visit.

Bumping the version by hand is a trap

I did this by hand at first: edit the file, remember to bump the number in every page that references it, deploy, and hope I did not miss one. I missed one, of course. The homepage got the new script, a tool page kept the old one, and I spent an evening debugging a bug that only existed on one page.

So I stopped trusting my memory and let PHP do it. On a plain PHP site with no build step, filemtime() gives you the file's last-modified time for free:

function asset($path) {
    $full = __DIR__ . '/' . ltrim($path, '/');
    $v = file_exists($full) ? filemtime($full) : time();
    return $path . '?v=' . $v;
}

Then every reference becomes <?= asset('/js/app.js') ?>, and the version bumps itself the moment the file changes on disk. No deploy checklist, nothing to forget. The timestamp is a Unix epoch, so it looks ugly in the HTML source, but nobody reads query strings for beauty.

One catch: filemtime() reads the disk on every request. For a handful of assets on shared hosting that is cheap enough to ignore, but if you serve dozens of files per page, write the version into a small config file and regenerate it at deploy time instead. Same idea, one less disk read.

Where it does not work

The trick is not perfect. Some older proxies and a few CDNs ignore query strings when deciding what to cache, and can serve the stale file anyway. The big CDNs handle it correctly today, but if your caching layer is unusual, filename versioning (app.20261106.js) is the bulletproof version of the same idea. It is also more work on a site with no build step, which is why I stayed with ?v=.

The other edge is the service worker. I run a network-first service worker on my downloader site, ramrom.io, and it caches pages and assets too. A cache-busted URL helps there as well, because the worker keys its cache by URL just like the browser does — but if the worker itself is stale, it can keep serving old responses no matter what the URL says. That is a separate problem, and it is why I version the service worker file as well.

The habit that stuck

Cache-busting is not a clever technique. It is a habit: never ship a changed static file under the same URL. The version can be a date, a timestamp, or a hash of the file contents — the format does not matter. What matters is that it changes when the file changes, and that the change happens automatically, because the one time you forget to bump it by hand is the one time a visitor reports a bug you already fixed.

These days I do not think about it at all. The helper writes the version, the browser fetches the new file, and the bug reports that used to say "it still looks broken" have quietly stopped coming. That silence is the whole point.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.