I Swapped an Immutable Asset at the Same URL, and Only the Browser Kept the Old Copy
Overwrite a long-cached JS file at the same URL and neither a CDN purge nor a refresh will make the browser fetch it again. Four delivery styles — same URL, no-cache, query string, content-hash filename — compared in Chromium, plus a complete build-step script that adds the hash.
I had finished a build and replaced a JS file under the same name. The CDN entry for that URL had been purged, so a fresh copy should have been on its way. In my own browser, though, the old behavior kept running.
The purge had probably worked. What hadn't moved was the layer one step closer to the reader — the browser itself. I had stepped on the other side of a rule I'd set for myself: give assets a long cache lifetime, and overwrite them in place.
Let me give you the conclusion first. If the content changes at the same URL, you can't give it a long cache lifetime. If you give it a long cache lifetime, the URL has to change when the content does. Below, I line that rule up against four delivery styles, show what Chromium actually did in my tests, and leave the script I now run at the end of the build.
Separate the layers first
When "the old one is still showing," three places could be holding it: the CDN edge, the reader's browser, and the URL your HTML points at. A purge reaches only the edge. It never touches the browser's cache.
Two lines in the browser console are enough to tell them apart. Fetch the same URL once with the cache allowed and once with it forbidden.
// What this solves: tell whether the browser, or something further upstream, holds the old copyconst u = '/assets/app.js'; // replace with the URL you suspectconst a = await (await fetch(u, { cache: 'default' })).text(); // may use the browser cacheconst b = await (await fetch(u, { cache: 'reload' })).text(); // always goes to the serverconsole.log({ same: a === b, defaultHead: a.slice(0, 60), reloadHead: b.slice(0, 60) });// Why it works: same === false means the browser is holding the old copy.// If same === true and it's still old, the edge or origin has it (check with curl -I separately).
That cache option is what I used to separate the layers. The moment default returned the old file and reload returned the new one, the cause was the browser, not the CDN — and every extra purge was wasted effort.
Four delivery styles, side by side
Here is how each style should behave when you swap the asset, on paper.
Style
URL
Cache-Control
Does the swap reach readers?
A Same URL, long + immutable
/assets/app.js
public, max-age=31536000, immutable
No
B Same URL, long only
/assets/app.js
public, max-age=31536000
No
C Same URL, always revalidate
/assets/app.js
no-cache + ETag
Yes (asks the server each time)
D Query string
/assets/app.js?v=2
long + immutable
Yes, once the HTML is fresh
E Content-hash filename
/assets/app.3f9a1c2e.js
long + immutable
Yes, once the HTML is fresh
That's the expectation. Now let me see what a real browser does.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦When a swapped asset still behaves like the old one, you'll be able to tell whether the CDN, the browser, or the HTML is holding it with two fetch calls using the cache option
✦You'll be able to choose among same-URL, no-cache, query-string, and content-hash delivery after checking each one yourself in a local Chromium, instead of trusting a table
✦You'll skip the re-investigation of 'I purged it and it's still old' on every release by running one hash-the-filenames script at the end of the build
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
I used a small Node server and Playwright's Chromium (141). The server switches Cache-Control per style and counts the requests it receives. Each case loads v1, changes the file on the server to v2, loads the page again, and reads which version actually ran.
// server.mjs (excerpt) — per-style headers and a request counterimport http from 'node:http';import crypto from 'node:crypto';let version = 1; // /__bump raises it to 2const hits = {}; // path -> requests that reached the serverconst body = () => `window.APP_VERSION = ${version};\n`;const etag = () => '"' + crypto.createHash('sha1').update(body()).digest('hex').slice(0, 12) + '"';const CC = { '/immutable/app.js': 'public, max-age=31536000, immutable', // A, D '/maxage/app.js': 'public, max-age=31536000', // B '/revalidate/app.js': 'no-cache', // C};http.createServer((req, res) => { const u = new URL(req.url, 'http://x'); const p = u.pathname; if (p === '/__bump') { version = 2; res.end('ok'); return; } if (p === '/__reset') { for (const k in hits) delete hits[k]; version = 1; res.end('ok'); return; } if (p === '/__hits') { res.setHeader('content-type', 'application/json'); res.end(JSON.stringify(hits)); return; } const m = p.match(/^\/hashed\/app\.([0-9a-f]{8})\.js$/); // E: the content behind a URL never changes if (m) { hits[p] = (hits[p] || 0) + 1; res.setHeader('cache-control', 'public, max-age=31536000, immutable'); res.setHeader('content-type', 'text/javascript'); res.end(`window.APP_VERSION = ${m[1] === 'aaaaaaaa' ? 1 : 2};\n`); return; } if (CC[p]) { hits[p] = (hits[p] || 0) + 1; res.setHeader('cache-control', CC[p]); res.setHeader('etag', etag()); if (req.headers['if-none-match'] === etag()) { res.statusCode = 304; res.end(); return; } res.setHeader('content-type', 'text/javascript'); res.end(body()); return; } if (p === '/page.html') { // HTML always revalidates; only the src changes res.setHeader('cache-control', 'no-cache'); res.setHeader('content-type', 'text/html'); res.end(`<!doctype html><script src="${u.searchParams.get('src')}"></script><body>x</body>`); return; } res.statusCode = 404; res.end('nf');}).listen(8765);
I set the HTML to no-cache on purpose. If the HTML were stale too, the test would tell me nothing. It's also the split I use in production: HTML revalidates, assets live long.
# run.py (excerpt) — first load, swap, revisit, reload, then fetch with each cache modeimport json, urllib.requestfrom playwright.sync_api import sync_playwrightB = 'http://127.0.0.1:8765'def g(p): return urllib.request.urlopen(B + p).read().decode()def hits(): return json.loads(g('/__hits'))# (label, first src, src after the swap, path used for the fetch probes)cases = [ ('A same URL + immutable', '/immutable/app.js', '/immutable/app.js', '/immutable/app.js'), ('B same URL + max-age', '/maxage/app.js', '/maxage/app.js', '/maxage/app.js'), ('C same URL + no-cache', '/revalidate/app.js', '/revalidate/app.js', '/revalidate/app.js'), ('D query ?v=2', '/immutable/app.js', '/immutable/app.js?v=2', '/immutable/app.js'), ('E content hash', '/hashed/app.aaaaaaaa.js', '/hashed/app.bbbbbbbb.js', '/hashed/app.aaaaaaaa.js'),]with sync_playwright() as pw: br = pw.chromium.launch(); print('chromium', br.version) for label, s1, s2, probe_path in cases: g('/__reset') # back to v1, counters emptied ctx = br.new_context(); page = ctx.new_page() # every case starts with an empty cache page.goto(f'{B}/page.html?src={s1}'); v_first = page.evaluate('window.APP_VERSION') g('/__bump') # change the content to v2 page.goto(f'{B}/page.html?src={s2}'); v_revisit = page.evaluate('window.APP_VERSION') v_reload = '-' if s1 == s2: page.reload(); v_reload = page.evaluate('window.APP_VERSION') probe = lambda mode: page.evaluate( "async ([u,m]) => (await (await fetch(u,{cache:m})).text()).trim()", [probe_path, mode]) print(label, v_first, v_revisit, v_reload, probe('default'), probe('reload'), sum(hits().values())) ctx.close() br.close()
Here are the results. I ran the same steps twice and got identical values both times.
Style
First load
Revisit after swap
Reload
fetch default
fetch reload
Server hits
A same URL + immutable
v1
v1
v1
v1
v2
2
B same URL + max-age only
v1
v1
v1
v1
v2
2
C same URL + no-cache
v1
v2
v2
v2
v2
5
D query ?v=2
v1
v2
n/a
v1 (old URL)
v2
3
E content hash
v1
v2
n/a
v1 (old hash)
v1 (old hash)
3
Three things stood out.
First, A and B behaved the same. I had assumed immutable was the word pinning the old copy. In this Chromium, max-age=31536000 alone was enough to keep it from going back to the server, even on a reload, and removing immutable fixed nothing. Where immutable matters is in browsers that revalidate on reload; I didn't see that difference here, so I'll stay inside what I tested and won't claim anything about other browsers.
Second, C does deliver the new file, but it also made the most trips to the server — five. Every visit asks "has this changed?", so unchanged days cost the same round trips. For a small site that's fine; on a page with many images and scripts, the asking adds up.
Third, the old URLs in D and E. Open the new HTML and the new version runs, but fetch the old query-less URL with default and you still get v1. Anything that still points at the old URL stays old. In E that's by design — a hashed URL is a promise that its content never changes — so it's the correct behavior, not a flaw.
Where I landed
I use E for assets and revalidation for HTML. Three reasons:
I keep the benefit of long caching: almost no traffic on repeat visits.
A swap reaches readers because the HTML points at a new URL, so I stop worrying about whether a purge succeeded.
Someone holding an old HTML page open doesn't break, as long as the old hashed files are still there.
D isn't bad. But some CDN settings ignore query strings, and bumping ?v= by hand leaves room for people to forget. E derives the hash from the content, so there's nothing to forget.
A script that adds the hash at the end of the build
This script appends an 8-character content hash to every file under dist/assets, renames it, and rewrites the references in the HTML directly under dist. Running it twice doesn't hash the names twice. I ran it in a temporary directory and checked the output below.
// hash-assets.mjs — add a content hash under dist/assets and rewrite the HTML references// Usage: node hash-assets.mjs distimport { readdirSync, readFileSync, renameSync, writeFileSync, statSync } from 'node:fs';import { createHash } from 'node:crypto';import { join, extname, basename } from 'node:path';const root = process.argv[2] ?? 'dist';const assetsDir = join(root, 'assets');const HASHED = /\.[0-9a-f]{8}\.[a-z0-9]+$/; // skip files that already carry a hashconst map = new Map(); // old path -> new pathfor (const name of readdirSync(assetsDir)) { const full = join(assetsDir, name); if (!statSync(full).isFile() || HASHED.test(name)) continue; const hash = createHash('sha256').update(readFileSync(full)).digest('hex').slice(0, 8); const ext = extname(name); const next = `${basename(name, ext)}.${hash}${ext}`; renameSync(full, join(assetsDir, next)); map.set(`/assets/${name}`, `/assets/${next}`);}// Only HTML is rewritten here (see the pitfalls for JS imports and CSS url())for (const name of readdirSync(root).filter((n) => n.endsWith('.html'))) { const p = join(root, name); let html = readFileSync(p, 'utf8'); for (const [from, to] of map) html = html.split(from).join(to); writeFileSync(p, html);}console.log([...map].map(([a, b]) => `${a} -> ${b}`).join('\n'));
Identical content gives an identical hash, so the URL of an unchanged asset doesn't move. Readers' browsers refetch only what actually changed.
On the delivery side, give only assets a long lifetime. On Cloudflare Pages, two lines in _headers are enough.
# _headers — one year for assets only; HTML stays on revalidation/assets/* Cache-Control: public, max-age=31536000, immutable
Leave the HTML's Cache-Control to your host's default or set no-cache explicitly. If the HTML becomes long-lived, the hashes are pointless.
Four pitfalls
References outside the HTML: if a JS import or a CSS url() still names the old file, that one reference 404s or stays old. Letting your bundler emit hashed names is the safe route. The script above is the minimal, HTML-only version.
Don't delete the previous hashed files: someone may still have an older HTML page open. Keep the prior generation until the next deploy, and clean up several generations at once.
Caching of the HTML itself: if the HTML is held for a long time, readers have no way to learn the new hashes. Before the day you add hashes, check the HTML's Cache-Control.
Don't verify only with an empty cache: a private window starts empty, so it can never show you the old copy. Before you call it fixed, check default against reload in your everyday browser.
What to ask an agent for
If you let an Antigravity agent write the hash script or the _headers file, I'd keep verification on your side. Here is an example of how I'd phrase the request.
Write a Node script that appends an 8-character content hash to every file under dist/assetsand rewrites the references in the HTML files directly under dist.Constraints: running it twice must not double-hash; don't touch anything except HTML;print the old-path -> new-path list at the end.Verify in a temporary directory and paste the exact commands and output you ran.
"Paste the output" is the point. Instead of being told it works, you get output you can read yourself. Check by whether the URL changed, not by whether the purge succeeded. I try to remember that line on every swap.
Before your next deploy, run the two fetch lines against an asset you've already swapped. If default and reload give different answers, that asset is a candidate for a hashed name.
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.