Advertisement

You deploy a fix. You reload and see it. A user reports the bug is still there, sends a screenshot of the old page, and a hard refresh makes it disappear. Nothing is broken in the build. Somewhere between your server and that browser, a copy of the old file is being served by something that believes it is still fresh.

Caching is what makes the web fast, and the cost of that speed is that you have to be explicit about lifetime. Every layer — the browser, the CDN, any proxy in between — will keep a copy for exactly as long as your headers say, and if you did not say, each one guesses differently.

What follows is what the headers actually mean, the pattern that resolves the tension between long caching and fast deploys, and the specific mistake that makes a fix invisible for a week.

What the headers actually say

Cache-Control is the one that matters most. Its max-age says how many seconds a copy may be reused without asking. Adding immutable says the file will never change, so a browser should not even revalidate it on a reload. no-cache is the most misread of the set: it does not mean "do not store", it means "store it, but ask before reusing it". The one that means do not store is no-store.

Then there is validation. ETag is a fingerprint of the content; Last-Modified is a timestamp. When a cached copy expires, the client can ask with that fingerprint, and if nothing changed the server answers with a small "not modified" instead of the whole file. That turns an expired cache into a cheap check rather than a full download.

The distinction worth holding: max-age controls how often the question is asked, and ETag controls how expensive the answer is. Long max-age plus validation gives you both — rare questions, cheap answers when they happen.

Advertisement

Long caching and instant deploys are not in tension

The apparent conflict is that caching a file for a year makes it fast and makes an update invisible for a year. The resolution is to stop updating files, and to publish new ones instead. Build tools do this by putting a content hash in the filename, so a changed file is a different URL. Old URL, old content, cached forever, harmlessly. New URL, new content, fetched immediately because nothing has ever seen it.

That works for anything with a generated name — scripts, stylesheets, images, fonts — which is most of a page's weight. Those can carry the longest max-age with immutable, and they never go stale, because the name changes whenever the bytes do.

The one file that cannot work this way is the HTML entry point, because its URL is the address users type. It has to be fetched fresh, or at most cached very briefly, since it is what points at all the hashed filenames. Get this split right — HTML short, hashed assets forever — and a deploy propagates in one request while almost everything stays cached.

The mistake that makes a fix invisible

The failure is a long max-age on the HTML. Now the document that names the new asset URLs is itself cached, so a returning visitor gets yesterday's document, which points at yesterday's assets, which are cached and valid. Every layer is behaving correctly and the user sees a completely consistent old version of your site.

What makes this expensive is that you cannot recall it. Once a browser has stored a response with a long lifetime, you have no way to reach in and invalidate it; you can purge your own CDN, but the copy in a user's browser stays until it expires. A mistaken year-long max-age on an HTML page is a year-long problem for anyone who loaded it during the window.

Which is why the safe default is the conservative one at the entry point and the aggressive one everywhere else — the opposite of the intuition that says cache the big things carefully and the small things freely. The HTML is small; caching it is where almost nothing is gained and everything can be lost.

Advertisement

Checking it rather than assuming it

This is all directly observable. Open the network panel, reload, and look at the response headers for the document and for one hashed asset. The document should show a short lifetime or a revalidation; the asset should show a long one. If both look the same, one of them is wrong, and it takes ten seconds to find out.

It also helps to know which layer you can reach. A CDN you control can be purged in seconds, so a mistake cached there is an inconvenience. A browser cache cannot be reached at all. That asymmetry is a design input, not trivia: it is the reason s-maxage exists, letting you tell shared caches a longer lifetime than browsers get, so the layer you can fix holds the copy and the layer you cannot holds very little.

The second check is a second browser, or a private window, after a deploy. Your own browser is the least representative client you own — it has been to the site more than anyone and holds the oldest cached copies. A fresh profile is what a real returning visitor is closest to.

One last habit: when a user reports something that a hard refresh fixes, record it as a caching bug rather than closing it as resolved. It is the only report you will get, and behind it are the users who did not think to hard-refresh and simply concluded the site was broken.

Why a service worker's own cache can outlast even a corrected server-side cache header

A previously registered service worker can keep serving a cached response indefinitely from its own separate cache storage, entirely independent of whatever `Cache-Control` header the server sends on subsequent requests, since the service worker intercepts the request before it ever reaches the network at all — fixing the server-side header alone does nothing for a user whose browser already has an old service worker actively serving stale content, which is exactly why service worker update strategies need their own explicit versioning and cache-invalidation logic, separate from ordinary HTTP caching.

Advertisement

Why a CDN's edge cache and a browser's own cache can disagree, and why that matters for debugging

A deploy that correctly purges a CDN's edge cache does not necessarily clear what an individual visitor's own browser has already cached locally, and a deploy that updates browser-facing cache headers going forward does nothing to retroactively expire a response a browser already cached under the old, longer-lived header — this is exactly why 'it works when I check the CDN directly but not in my own browser' is a common, genuinely confusing symptom immediately after a caching-related deploy, and diagnosing it requires checking both layers independently rather than assuming they are always in sync.

Cache busting by filename versus by header

There are two fundamentally different strategies for making sure a client picks up a new file, and mixing them up is where most of these incidents come from. The first strategy is cache busting by filename: every build produces assets with a content hash baked into the name, like main.a3f9c1.js, so a new build is physically a new URL that no cache has ever seen before, stale or not. The second strategy is cache busting by header: the filename stays the same, main.js, and you rely entirely on cache-control and etag headers to tell caches when the content behind that name has changed.

Hashed filenames are dramatically more robust because they sidestep the whole cache invalidation problem instead of trying to solve it. If the content changes, the name changes, and every cache in the path — browser, CDN edge, corporate proxy, service worker — automatically treats it as a new resource with no coordination required. The only thing you need cache headers for at that point is the index.html or entry document that references the hashed assets, and that one file should be marked no-cache so it is always revalidated, while everything it points to can be cached forever as immutable.

Teams that skip content hashing and rely purely on cache-control headers for their main bundle are betting that every cache in the world respects those headers correctly and that no intermediate proxy strips or rewrites them, which is a bet you will eventually lose. The fix once you have been burned by this is almost always the same: move to hashed, immutable filenames for anything that can be, and reserve short-lived or no-cache headers only for the one document that has to stay at a stable URL by nature — the HTML entry point that bookmarks and links point to.

Why this bug is specifically hard to reproduce

One of the most frustrating properties of a stale-cache incident is that it resists reproduction from the machine that is investigating it. The engineer who deployed the fix opens the site, hard-refreshes, sees the new version, and concludes the deploy worked — because their own hard refresh bypassed exactly the cache that is still serving stale content to everyone who did not hard refresh. Every act of debugging the problem tends to also accidentally fix it for the person doing the debugging, which hides the scope of who is still affected.

This is why the right first move when you suspect a caching issue is not to reload your own browser but to check what a cold, uncached client would see — a private/incognito window with no service worker registered, a request from a server with curl showing the actual response headers, or a CDN's own cache-status header showing hit or miss. Any of these give you a view into the cache layer's actual state instead of your personal browser's state, which after a manual refresh is no longer representative of anyone else's experience.

Setting max-age deliberately instead of copying a default

A surprising number of these incidents trace back to a cache-control value that was never chosen on purpose — it was copied from a tutorial, a boilerplate config, or a previous project without anyone re-deriving what value actually fits the resource in question. A max-age of one year makes sense for a hashed, immutable asset that will never be reused under that name once its content changes, but the same one-year value on an unhashed file that gets edited in place is a guaranteed staleness incident waiting for the next deploy.

The healthier default for anything that is not content-hashed is a short max-age, often measured in minutes, paired with a must-revalidate or stale-while-revalidate directive that lets a cache serve a slightly stale copy while it checks in the background for a fresher one. This gives you most of the performance benefit of caching without the multi-hour blast radius of a cache that was told to trust its copy for a year.