How to Keep Caching From Serving Stale Pages After an Update
Cache busting static assets means changing the URL of a file whenever its contents change, so browsers fetch the new version instead of reusing the old one. The HTML document is the one thing you keep short lived, and everything it references gets a content-derived name that can be cached forever.
The reason returning visitors see stale pages is almost never the server. It is the browser, and sometimes a CDN or reverse proxy in front of it, obeying a cache lifetime you set earlier and holding on to a copy of the old CSS, JavaScript or HTML. The fix is not to switch caching off. It is to make the cache key change when the content changes, and to keep the HTML on a short leash so it can point at the new keys.
Understand what the cache is actually keyed on
An HTTP cache stores a response against a key built from the request method and the full URL, including the query string. A response for /app.css and one for /app.css?v=8f3a1c are different entries. That single fact is the whole mechanism behind cache busting static assets. If you change the URL, you get a new entry. If you keep the URL and the old entry has not expired, the browser serves the old bytes, and it may not even ask your server whether anything changed.
Two response headers control that decision. Cache-Control sets the lifetime and the revalidation rules. ETag or Last-Modified gives the browser something to check against when it does revalidate. A typical long-lived asset response looks like this:
Cache-Control: public, max-age=31536000, immutable
ETag: "8f3a1c9d"
The immutable directive tells the browser not to revalidate even on a normal reload, which is safe only because the URL itself changes whenever the file changes. For the HTML document you want the opposite: a short lifetime, or no cache at all, plus revalidation.
Cache-Control: no-cache
ETag: "b21e07"
no-cache does not mean "do not store". It means "store it, but check with me before using it". That is what you want for HTML: the browser keeps a copy, sends a conditional request with If-None-Match, and your server answers 304 Not Modified when nothing has changed. Cheap, and never stale.
Version the filenames with a content hash
The most reliable approach is to put a hash of the file contents into the filename itself, for example app.8f3a1c9d.css. Filenames are cleaner than query strings because some proxies and older caches are configured to ignore or strip query strings, which quietly defeats the whole scheme. A content hash also means the URL changes only when the bytes change, so a rebuild that produces identical output keeps the same name and the cache stays warm.
Most build tools do this for you. The pattern is to emit hashed filenames and produce a manifest that maps the logical name to the hashed one, then have your templates read the manifest. If you are doing it by hand, a short hash is enough:
sha256sum public/css/app.css | cut -c1-8
# 8f3a1c9d
Rename the file to app.8f3a1c9d.css, reference that name in your HTML, and serve it with the long max-age and immutable header above. When you edit the CSS, the hash changes, the filename changes, and every returning visitor requests a URL that has no cached entry. You have invalidated the old copy without touching the cache configuration.
Serve the versioned files from a path you can cache hard
Put versioned assets under a stable prefix such as /static/ and apply the long-lived header rule to that prefix only. Keep HTML and anything user-specific outside it. If you run a web server directly, the configuration is a location block or an equivalent rule matched on the path prefix, setting Cache-Control for /static/ and a short lifetime everywhere else. The exact directive name depends on your server, so check its documentation for header and location matching rather than copying a snippet blindly.
Keep the HTML short lived so it can point at new names
Versioned filenames only help if the browser gets the new HTML. If your HTML document is cached for a day, a visitor who returns within that window keeps the old document, which references the old asset names, and the update is invisible no matter how well the assets are versioned. This is the trap most stale-page reports come from.
Set the HTML response to Cache-Control: no-cache and include an ETag. The browser will revalidate on each navigation and get a 304 when nothing changed, so the cost is one small conditional request. If you front the site with a CDN, make sure the HTML is not being cached at the edge for longer than you intend, or purge it explicitly on deploy. Purging is a fallback, not a strategy: it depends on the purge succeeding everywhere, and it does nothing for a browser that already holds a copy.
Force a refresh when you have no other option
Sometimes you inherit a setup where a file is served from a fixed URL with a long lifetime and you cannot rename it. In that case you can append a version query string to the reference in your HTML, for example app.css?v=8f3a1c9d, and change the value on each release. It works, but treat it as a stopgap. Query strings are more fragile than filenames, and the old URL stays cached until it expires.
When you need to confirm what a client is actually receiving, ask the server and read the headers rather than guessing. A conditional request shows you whether your ETag is doing its job:
curl -sI https://example.test/static/app.8f3a1c9d.css
# HTTP/1.1 200 OK
# Cache-Control: public, max-age=31536000, immutable
# ETag: "8f3a1c9d"
Run the same request against your HTML URL and confirm it returns no-cache and an ETag. If a proxy sits in front, check its response too, since it may rewrite or add headers of its own. The header you see at the edge is the one the browser obeys.
What to do next
Audit your current responses with curl -I against one asset and one HTML page, and write down the Cache-Control each one returns. Then decide which files can carry a content hash in the name and move them under a prefix with a long lifetime, and make sure your HTML is revalidated on every visit. If you use a build tool, confirm the manifest is wired into your templates so the hashed names actually reach the page. Test it the way a returning visitor would: load the site, deploy a change, reload without clearing the cache, and check that the new asset URL appears in the page source.
