Caching headers fail quietly. A directive that contradicts another one is not an error, it is just a response that nobody caches, or worse, one that a CDN caches with a stranger's session in it. This page explains what the analyzer computes, what each directive means, and which combinations are worth fixing.
What the analyzer computes
Two questions matter, and they have different answers. How long does the browser keep this response, and how long does a CDN or proxy keep it? A response can be fresh for a year in one and not stored at all in the other, which is exactly what you want for a personalised page and exactly what you do not want for a static asset.
The tool answers both from the headers you paste, applying the same order of precedence a cache does: no-store first, then no-cache, then s-maxage for shared caches, then max-age, then Expires minus Date, then the heuristic. It reads Age to tell you how much freshness is already spent, checks whether a validator exists for revalidation, and folds Vary into the cache key.
How to read the report
Paste a header block from devtools (right click a request, copy response headers) or from curl -I. A status line is ignored. If you only have the value of a Cache-Control header, paste that on its own and the tool reads it as one.
- Who caches this. One row for the browser, one for CDNs and proxies, each with the computed lifetime and the rule that produced it. This is the answer to the question you came with.
- Findings. Contradictions and missing pieces, errors first.
- Directives in plain english and the headers that were read, so you can see nothing was misparsed.
--cdn
Lets a targeted cache-control header override Cache-Control for the shared-cache verdict, in the order Cloudflare-CDN-Cache-Control, CDN-Cache-Control, Surrogate-Control. Turn it off to see what a CDN that ignores those headers would do.
--heuristic
Applies the 10 percent rule when a response carries no explicit lifetime. It is on by default because that is what caches really do, and switching it off shows you the theoretical answer of "nothing is cached" that a lot of documentation implies.
The directives, in plain english
| Directive | Meaning | Applies to |
|---|---|---|
max-age=N | Reusable for N seconds without asking | every cache |
s-maxage=N | The same, overriding max-age | shared caches only |
no-cache | Store it, but revalidate before every reuse | every cache |
no-store | Do not write it anywhere | every cache |
private | Browser may store, shared caches may not | every cache |
public | Storable even when the request was authenticated | shared caches |
must-revalidate | Once stale, never serve it, not even when the origin is down | every cache |
immutable | Do not revalidate on reload | browsers |
stale-while-revalidate=N | Serve stale for N seconds while refreshing in the background | every cache |
stale-if-error=N | Serve stale for N seconds if the origin errors | every cache |
no-transform | Proxies may not recompress or rewrite the body | intermediaries |
Anything not on this list is ignored without warning, which makes a typo invisible. no cache with a space parses as two unknown directives and produces a response that is cached for as long as the heuristic decides. The report flags unknown directives for exactly that reason.
How freshness is calculated
A cache asks two things: may I store this, and is the stored copy still fresh. Freshness comes from the first of these that exists, in the order RFC 9111 lays down.
s-maxage, for shared caches only.max-age.ExpiresminusDate. Both are absolute timestamps, and the subtraction is what makes a wrong server clock harmless.- The heuristic: 10 percent of the gap between
Last-ModifiedandDate.
The Age header then says how many seconds the copy has already spent in caches, so the remaining freshness is the lifetime minus the age. A response that arrives with max-age=600 and Age: 580 is fresh for twenty more seconds, which is often the missing piece when a CDN keeps serving something that looks like it should have expired.
What happens after freshness runs out is where the validators come in. With an ETag, the cache sends If-None-Match and usually gets a 304 with no body. With Last-Modified it sends If-Modified-Since, which is weaker because a rebuild that does not change the content still changes the timestamp. With neither, revalidation means downloading the whole thing again, and the caching you configured saves nothing but a little latency.
Combinations that contradict
These are the ones the analyzer flags, and the reason each is worth changing:
no-cachewithmax-age. no-cache binds every cache, browser and CDN alike, so revalidation happens before each reuse and the lifetime next to it never applies. The header reads as though the response is cached for an hour, and it is not.no-storewith anything else. Nothing is stored, so every other directive on the line is decoration that hides the actual behaviour from the next person reading it.privatewiths-maxage. s-maxage addresses shared caches and private tells them not to store the response at all.publicon a response withSet-Cookie. The dangerous one. A shared cache may store the response including the cookie and hand it to the next visitor, which is how session-mixing incidents happen. If the response must be cached, strip the cookie.must-revalidatewithout a lifetime. The response is stale on arrival, so there is nothing for must-revalidate to guard.Expiresnext tomax-age. Harmless, and max-age wins everywhere. Keep Expires only if a very old intermediary is in the path.Expires: 0. Not a valid HTTP date, and treated as a date in the past. It works, by accident, andno-storesays the same thing on purpose.Pragma: no-cacheon a response. A request header from HTTP/1.0 with no defined meaning on a response. Every cache ignores it.Vary: *. A cache key that never matches. The compliant way to write "never reuse this".
Different lifetimes for browser and CDN
The classic setup is a short browser lifetime and a long CDN lifetime, so a purge takes effect immediately for everyone while origin traffic stays low. There are two ways to express it.
The portable one is s-maxage: Cache-Control: public, max-age=60, s-maxage=86400 gives the browser a minute and the CDN a day. Every compliant shared cache understands it.
The newer one is a targeted header from RFC 9213: CDN-Cache-Control is read only by CDNs, while browsers keep following Cache-Control. Cloudflare adds Cloudflare-CDN-Cache-Control above it, Fastly and Akamai have long supported Surrogate-Control. The advantage over s-maxage is that the two headers are independent, so you can send no-store to the browser and a day of caching to the CDN, which s-maxage cannot express. The catch is that a CDN which does not implement the header falls back to Cache-Control, so the fallback has to be correct on its own.
Headers worth copying
| Response | Header |
|---|---|
Fingerprinted asset (app.4f2c1b.js) | Cache-Control: public, max-age=31536000, immutable |
| HTML document | Cache-Control: no-cache plus an ETag |
| Public API response that changes slowly | Cache-Control: public, max-age=60, stale-while-revalidate=600 |
| Logged-in page | Cache-Control: private, no-cache |
| Anything with personal data | Cache-Control: no-store |
| Image served through a CDN you purge | Cache-Control: public, max-age=300, s-maxage=31536000 |
The pattern behind all of them: cache aggressively where the URL changes with the content, revalidate where it does not, and never let a shared cache store something personal. Our own take after untangling a few of these in production is that the HTML row is the one people get wrong most often. A homepage with max-age=3600 means an hour where a deploy has not happened for anyone who visited recently, and no purge can fix it.
The guide on HTTP caching covers ETags and the heuristic in more detail. If the response you are debugging also sets cookies, the Set-Cookie parser checks the other half of the same header block.
Directive questions
What does Cache-Control: no-cache actually mean?
It means store the response, but ask the origin whether it is still valid before every reuse. It does not mean "do not cache", which is the single most common misreading of the whole spec. The directive that prevents storage is no-store. With no-cache and an ETag, a repeat request costs one small round trip that usually answers 304 Not Modified, so the body is never re-downloaded. With no-store, every request downloads everything again.
What is the difference between max-age=0 and no-cache?
In practice almost nothing, in theory a little. max-age=0 makes the response stale immediately, so a cache has to revalidate before reuse. no-cache says the same thing more explicitly and cannot be overridden by a heuristic. The one real difference appears with must-revalidate: max-age=0 alone allows a cache to serve the stale copy when the origin is unreachable, while no-cache does not. If you want revalidation on every request, no-cache is the clearer way to say it.
What is the difference between max-age and s-maxage?
max-age applies to every cache, s-maxage only to shared ones, meaning CDNs and proxies rather than browsers. Where both are present a shared cache uses s-maxage and ignores max-age, while a browser only ever reads max-age. That is the standard way to keep a short lifetime in the browser and a long one on the CDN, for instance max-age=60, s-maxage=86400 on a page you purge from the CDN when it changes.
How long does a browser cache a response with no Cache-Control header?
It guesses. RFC 9111 allows heuristic freshness, and the widely implemented rule is 10 percent of the time between the Last-Modified date and the Date header, so a document last changed 100 days ago is treated as fresh for 10 days. Browsers cap this, but the exact cap differs per engine. This is why an API response nobody meant to cache comes back stale from a CDN, and it is the reason to send an explicit Cache-Control on everything, including responses you do not want cached.
What does immutable do?
Cache-Control: immutable tells the browser the body will never change under this URL, so it should not revalidate even when the user hits reload. Without it, a reload fires a conditional request per asset, and on a page with 40 fingerprinted files that is 40 round trips returning 304. It is only safe on URLs that carry a content hash, such as app.4f2c1b.js. Put it on a URL that can change and users will keep the old file for as long as max-age says.
Why is my CDN not caching the response?
The usual reasons, in order: the response carries a Set-Cookie header, which most CDNs treat as uncacheable unless you say otherwise; Cache-Control contains private or no-store; Vary lists a header that changes per request, with User-Agent and Cookie being the usual culprits; the request method is not GET or HEAD; or the response has no explicit freshness and the CDN refuses to guess. The report above answers this directly by computing the shared-cache verdict separately from the browser one.
What does Vary do to caching?
Vary adds request headers to the cache key, so a separate copy is stored per distinct value. Vary: Accept-Encoding is normal and necessary, since gzip and brotli bodies differ. Vary: User-Agent is close to fatal, because there are millions of distinct UA strings and the cache fragments into millions of near-identical entries. Vary: * is the strongest form and means the response is never reusable at all. If your CDN hit rate looks impossibly low, check Vary before anything else.
What is stale-while-revalidate?
An extension from RFC 5861 that lets a cache serve the stale copy immediately while it refreshes in the background, for the number of seconds you give it. The visitor who arrives just after expiry gets a fast stale response instead of waiting for the origin. Combined with a short max-age it gives you both freshness and speed: max-age=60, stale-while-revalidate=600 means at most one visitor per minute waits for the origin. Its sibling stale-if-error does the same when the origin returns 5xx.
Do ETag and Last-Modified matter if I set max-age?
Yes, for what happens after the response goes stale. A validator is what lets a revalidation answer 304 Not Modified with no body, which is the difference between a 200-byte round trip and re-downloading the file. Without an ETag or a Last-Modified, every revalidation is a full download and most of the benefit of caching is gone. ETag is the stronger of the two because it survives a rebuild that changes the timestamp without changing the content.
How do I stop a page from being cached at all?
Cache-Control: no-store on its own does it. Everything else people add is cargo: Pragma: no-cache is a request header from HTTP/1.0 and has no meaning on a response, Expires: 0 is redundant next to no-store, and must-revalidate has nothing to guard when nothing is stored. Send no-store on responses that contain personal data or a session, and remember that no-store does not stop the browser from keeping the page in its back-forward cache.
What is the difference between private and no-store?
private allows the browser to store the response and forbids shared caches from doing so. no-store forbids storage everywhere. private is right for a personalised page that the same user may reasonably see again from their own cache, such as a logged-in dashboard. no-store is for responses that should not exist on disk at all, such as a password reset page or a bank statement. Using no-store where private would do makes the site slower for no benefit.
Why does my browser still show the old file after a deploy?
Because a cached response that is still fresh is not re-fetched at all, and there is no way to reach into a browser cache from the server. A long max-age you sent yesterday is binding until it runs out. The fix is not a header, it is the URL: give the file a content hash, cache it for a year with immutable, and change the URL whenever the content changes. For HTML, which cannot be fingerprinted, use no-cache so the document is always revalidated while the assets it points to stay cached.