
Cache Invalidation Is Hard Because You Are Doing It Wrong
Table of Contents
- The Invalidation Treadmill
- Immutable URLs Remove the Problem
- The Cache Headers That Actually Matter
- Stale-While-Revalidate Changes the Trade-Off
- Cache Keys and Accidental Fragmentation
- Caching Dynamic and Personalised Content
- Thundering Herds and Origin Protection
- Measuring Cache Effectiveness
- Common Pitfalls
- Conclusion
- Frequently Asked Questions
Key takeaway: Most cache invalidation problems disappear when URLs are content-addressed. If the URL changes whenever the content changes, the cache never holds anything stale and can cache forever.
The Invalidation Treadmill
A deployment ships new CSS. The site breaks for returning visitors because their browsers hold the old file. Someone adds a cache purge to the deployment pipeline. The purge takes several minutes to propagate globally, during which some users get old CSS with new HTML.
Then a purge is missed and the problem recurs. Then the purge is made more aggressive, which reduces the hit rate and increases origin load. Then someone shortens the cache lifetime to limit exposure, which reduces the hit rate further.
Each step is a reasonable response to the previous problem, and the cumulative result is a caching setup that provides less benefit while requiring more operational attention.
The underlying error is treating a URL as a location and the content at that location as mutable. Given mutable content at a stable address, invalidation is necessary and inherently racy — there is no way to atomically update every cache everywhere.
The alternative is to make the address change when the content does. Then nothing is ever stale, purging is unnecessary, and cache lifetimes can be effectively infinite.
Immutable URLs Remove the Problem
Content-addressed URLs embed a hash of the content in the filename.
Mutable, requires invalidation:
/assets/app.css Cache-Control: max-age=300
Immutable, never requires invalidation:
/assets/app.a3f91c2e.css Cache-Control: max-age=31536000, immutable
The mechanism: build tooling hashes each asset and includes the hash in its name. The HTML references the hashed filename. When content changes, the hash changes, so the filename changes, so it is a different URL that no cache has seen.
The consequences are all favourable. Assets cache for a year rather than minutes. No purge is ever needed for them. Rollback works correctly, because the old deployment’s HTML references the old hashes, which are still cached and still available. There is no window where new HTML pairs with old assets, since the HTML names the exact assets it requires.
The immutable directive is worth including specifically: it tells browsers not to send revalidation requests even when the user reloads, which eliminates a class of unnecessary round-trips.
What remains uncacheable-forever is the HTML itself, because it must be fetched to discover the current asset hashes. HTML gets a short cache lifetime or a revalidation requirement. That is one document rather than dozens of assets, and it is a substantially smaller problem.
This pattern is standard in modern build tooling and remains under-applied to assets outside the build — uploaded images, fonts, downloadable documents. The same approach works for any of them.
The Cache Headers That Actually Matter
Cache behaviour is determined by a small number of directives, and the distinctions between them are frequently misunderstood.
| Directive | Effect |
|---|---|
max-age=N |
Browser caches for N seconds |
s-maxage=N |
Shared caches use N instead of max-age |
no-cache |
Must revalidate before use — does not mean do not store |
no-store |
Never write to any cache |
private |
Browser may cache, shared caches may not |
public |
Cacheable even when normally it would not be |
immutable |
Do not revalidate, even on reload |
stale-while-revalidate=N |
Serve stale for N seconds while refreshing |
stale-if-error=N |
Serve stale for N seconds if origin fails |
The no-cache versus no-store confusion causes real problems in both directions. no-cache permits storage and requires validation before each use — appropriate for content that changes unpredictably but is not sensitive. no-store prohibits storage entirely and is what sensitive content requires. Using no-cache on a bank statement is a mistake; using no-store on a frequently-changing public page discards available performance.
The separation of s-maxage from max-age is the most useful and least used feature here. It permits a short browser cache with a long shared cache — the CDN holds content for an hour and serves it instantly while browsers revalidate every minute. This combination gives near-origin freshness with near-zero origin load, and it is available with two directives.
Stale-While-Revalidate Changes the Trade-Off
This directive resolves the fundamental caching tension more elegantly than any tuning of lifetimes.
Normally a cache entry is either fresh, in which case it is served, or expired, in which case someone waits for a revalidation. The user who happens to arrive just after expiry pays the full origin latency.
With stale-while-revalidate, an expired entry is served immediately while the cache refreshes in the background. Nobody waits. The next request receives the updated content.
Cache-Control: max-age=60, stale-while-revalidate=3600
This says: fresh for a minute, and for the following hour serve the stale copy instantly while fetching a new one behind the request. The practical effect is that origin latency is removed from the user’s path entirely for cached content, while content stays within a minute of current under sustained traffic.
The companion directive stale-if-error provides resilience rather than performance: if the origin returns an error or is unreachable, serve the stale copy rather than an error page. A generous value here means an origin outage degrades freshness rather than availability, which is frequently a better failure mode than the alternative.
Together these two directives change what caching provides — from a performance optimisation to a partial availability guarantee.
Cache Keys and Accidental Fragmentation
The cache key determines what counts as the same resource. Getting it wrong either fragments the cache or serves the wrong content to someone.
The default key is typically the URL. Additions to it come from the Vary header and from CDN configuration.
Where fragmentation happens accidentally:
Vary on User-Agent. Every distinct user agent string becomes a separate cache entry. Since user agent strings are effectively unique, this reduces the hit rate to near zero. It is a common mistake with a severe effect.
Query parameters included indiscriminately. Tracking parameters, session identifiers, and campaign tags create separate entries for identical content. Normalising the key to include only parameters that affect the response is frequently the single largest available hit rate improvement.
Cookies in the key. Any cookie difference fragments the cache. Since most sites set analytics cookies with unique values, including cookies in the key eliminates caching entirely.
Where fragmentation is necessary: Vary: Accept-Encoding for compression variants, Vary: Accept-Language where content is genuinely localised, and Vary: Accept where content negotiation occurs.
The reverse error is more dangerous. Failing to vary on something that does affect the response means one user’s content is served to another. Personalised content cached without varying on the identity is a data leak, and it has happened at significant scale to organisations that should have caught it.
The discipline: enumerate what actually changes the response, vary on exactly those, and normalise everything else out of the key.
Caching Dynamic and Personalised Content
“Dynamic content cannot be cached” is usually false and worth examining case by case.
Short-lifetime caching. Content that changes every few minutes can cache for a minute. Under sustained traffic, that converts most requests into cache hits while keeping content near-current.
Fragment separation. A page that is mostly identical with a personalised header can be cached as a shell with the personalised portion loaded separately, or assembled at the edge from cached and uncached fragments.
Cache per segment rather than per user. Content that varies by country, language, or plan tier has a small number of variants. Caching per variant gets most of the benefit; caching per user gets none.
Authenticated content with private. Browser caching without shared caching is appropriate for personalised content that the individual user revisits.
Cache the expensive computation, not the response. Where the response must be assembled per user, caching the costly intermediate results — a recommendation list, an aggregate — captures the benefit without caching personalised output.
The general approach is to separate what varies from what does not, then cache the invariant portion aggressively. A page where five percent of the content is personalised does not require the other ninety-five percent to be regenerated per request.
Thundering Herds and Origin Protection
A popular cache entry expiring under high traffic sends every concurrent request to the origin simultaneously. This is a self-inflicted load spike, and it occurs precisely when traffic is highest.
Protections, in order of importance:
Request coalescing. The cache forwards one request to the origin and holds the others until it returns. Most CDNs support this and it is sometimes not enabled by default — verifying it is worth the few minutes.
Stale-while-revalidate. Eliminates the herd entirely, since nobody waits for revalidation.
Jittered expiry. Adding randomness to cache lifetimes prevents many entries expiring simultaneously after a deployment or a purge.
Background refresh before expiry. Refreshing popular entries proactively means they never expire under load.
A negative cache with a short lifetime. Caching 404 and error responses briefly prevents repeated requests for missing resources from reaching the origin.
That last item addresses a specific and common problem: a missing asset referenced from a popular page generates origin traffic on every request forever, because errors are typically not cached. A short negative cache lifetime resolves it.
Measuring Cache Effectiveness
Metrics that indicate whether caching is working:
Hit ratio, by content type. Aggregate hit ratio conceals the important detail. Static assets should be very high; HTML will be lower. A low static asset hit ratio indicates a cache key problem.
Origin request rate relative to total requests. The direct measure of protection.
Bandwidth served from cache versus origin. Frequently a cost consideration as much as a performance one.
Latency at the p95, split by cache status. Confirms that hits are actually fast and quantifies what misses cost.
Stale served count. With stale-while-revalidate configured, this indicates how often the mechanism is engaging.
The diagnostic worth running when hit ratio is disappointing: examine the actual cache keys being generated. Fragmentation from query parameters or an inappropriate Vary header is the most common cause, and it is immediately visible once you look at the keys rather than the ratio.
Common Pitfalls
Purging as the invalidation strategy. Content-addressed URLs remove the need.
no-cache where no-store was intended. The first permits storage; only the second prohibits it.
Vary: User-Agent. Fragments the cache to uselessness.
Query parameters in the key without normalisation. Tracking parameters create duplicate entries for identical content.
Caching personalised content without varying on identity. A data leak, and it has happened publicly.
No request coalescing. Expiry under load becomes an origin spike.
Not caching error responses. A missing popular asset generates permanent origin traffic.
Conclusion
Cache invalidation is difficult because mutable content at stable URLs makes it inherently racy. Content-addressed URLs eliminate the problem for assets — the filename changes when the content does, so caches never hold anything stale and lifetimes can be a year.
For content that must remain at a stable address, the useful tools are s-maxage to separate shared cache behaviour from browser behaviour, stale-while-revalidate to remove origin latency from the user’s path entirely, and stale-if-error to convert an origin outage into a freshness problem rather than an availability one.
Then examine your cache keys, because fragmentation from query parameters and inappropriate Vary headers is the most common reason hit ratios disappoint. And verify request coalescing is enabled, since without it every popular expiry becomes an origin spike at exactly the wrong moment.
Frequently Asked Questions
What cache lifetime should assets use? A year, with the immutable directive, provided filenames contain a content hash. Without content hashing, the lifetime is a compromise between staleness and hit rate, which is the problem hashing removes.
Can HTML be cached? Briefly, or with revalidation. A short s-maxage with stale-while-revalidate works well — the CDN serves instantly while keeping content close to current.
How is personalised content handled? Separate the personalised fragment from the cacheable shell, cache per segment rather than per user where variants are few, or use private for browser-only caching. Never cache personalised responses in a shared cache without varying on identity.
Is no-cache sufficient for sensitive data? No. It permits storage and requires revalidation. Sensitive content needs no-store to prohibit storage entirely.
Why is the hit ratio low despite long lifetimes? Almost always cache key fragmentation — query parameters, cookies, or an overly broad Vary header. Inspect the generated keys directly rather than reasoning from the ratio.
Should error responses be cached? Briefly, yes. Caching 404s for a short period prevents repeated requests for a missing resource from reaching the origin indefinitely.
Does a CDN help for API responses? For read-heavy endpoints with cacheable responses, substantially. Even a short lifetime removes most origin load under sustained traffic. Write endpoints and per-user data need private or no-store.



