Whose answer this is
Caching questions are usually answered by quoting RFC 9111 at each other, and the quoting is where the
mistakes come from. This page instead reproduces one real implementation:
http-cache-semantics 4.2.0, the cache-decision engine behind
got, cacheable-request and make-fetch-happen. Every verdict above is
recorded from that library running for real and replayed as a test, so what you see is what it decides —
not what we think the specification says.
That library is not the specification, and in a handful of places the two differ. Those places are named on the page, under the verdicts, whenever your headers reach one: what this engine does, and what RFC 9111 says. Your CDN is a third implementation again, so treat this as a precise answer from one well-used engine rather than a promise about a particular vendor.
The four questions, in the order a cache asks them
- May I store this at all? The request method, the status code,
no-store,privateand anAuthorizationrequest header all decide this before any lifetime is worked out. - How long does it stay fresh?
s-maxagebeatsmax-agebeatsExpires— but only for a shared cache. With none of them, a cache may guess fromLast-Modified. - How old is it already? The
Ageheader counts time the response spent in caches upstream, and it is subtracted from the lifetime before anything else happens. - Can I answer this request from the copy? Freshness is necessary and not sufficient:
no-cache,must-revalidate,Varyand the request's own directives all get a say.
What each directive does, in each kind of cache
Every cell is produced by running this page's engine, and checked by a test — so this table cannot drift
from the behaviour above. The responses carry a Date header and nothing else.
| Cache-Control | Shared cache (CDN) | Private cache (browser) | What it means |
|---|---|---|---|
| max-age=600 | Stored, fresh for 10 minutes | Stored, fresh for 10 minutes | The plain answer: every cache may keep it, and it is fresh for ten minutes from the response Date. |
| s-maxage=600 | Stored, fresh for 10 minutes | Stored, stale immediately | Only a shared cache sees this. A browser has no lifetime from it at all, so it revalidates on every use. |
| max-age=60, s-maxage=600 | Stored, fresh for 10 minutes | Stored, fresh for 1 minute | The common CDN pattern: a long life at the edge, a short one in the browser. s-maxage also overrides Expires. |
| no-cache | Stored, stale immediately | Stored, stale immediately | Store it, but never serve it without asking the origin first. This is not "do not cache" — the copy is kept, and a 304 makes reuse cheap. |
| no-store | Not stored | Not stored | The one that really means do not keep it. Nothing is written down, so there is nothing to revalidate. |
| private, max-age=600 | Not stored | Stored, fresh for 10 minutes | The browser keeps it; the CDN refuses it outright. This is how a per-user page stays out of a shared cache. |
| public, max-age=600 | Stored, fresh for 10 minutes | Stored, fresh for 10 minutes | public mainly matters when something else would have blocked storage — an Authorization request header, or a Set-Cookie in the response. |
| must-revalidate, max-age=600 | Stored, fresh for 10 minutes | Stored, fresh for 10 minutes | Forbids serving the response once it is stale. This engine goes further and revalidates every time — see the divergences below. |
| proxy-revalidate, max-age=600 | Stored, stale immediately | Stored, fresh for 10 minutes | The same idea aimed at shared caches only; a browser ignores it. |
| public, max-age=31536000, immutable | Stored, fresh for 1 year | Stored, fresh for 1 year | The fingerprinted-asset pattern. immutable stops a browser revalidating on a reload while the response is still fresh. |
| max-age=60, stale-while-revalidate=600 | Stored, fresh for 1 minute | Stored, fresh for 1 minute | For ten minutes after it goes stale, the stored copy is served straight away and refreshed in the background. |
| max-age=60, stale-if-error=600 | Stored, fresh for 1 minute | Stored, fresh for 1 minute | Keeps the copy usable for ten minutes past staleness, but only if the origin fails. It shows up as a longer time-to-live, not a longer freshness lifetime. |
Results that surprise people
Each verdict here is the one this tool gives, computed when the page was built. Every response also carries
Date: Thu, 01 Jan 2026 00:00:00 GMT, which is left out of the column below only to keep it readable — the
lifetimes are measured from it. Paste any case above to see the deciding line.
| Case | Result | Why it catches people |
|---|---|---|
| A stray Set-Cookie Cache-Control: max-age=600 Set-Cookie: session=abc; Path=/; HttpOnly Shared cache | Stored — stale immediately | The response is stored, and it is stale the instant it arrives, so every request still reaches the origin. This looks exactly like a cache that is working and never hits. |
| s-maxage, seen from a browser Cache-Control: max-age=60, s-maxage=600 Private cache | Stored — fresh for 1 minute | The same header that gives the CDN ten minutes gives the browser one. Test the cache you are actually asking about. |
| no-cache Cache-Control: no-cache Shared cache | Stored — stale immediately | no-cache stores the response. It is no-store that does not. Reaching for no-cache to keep something out of a cache stores it anyway. |
| An Authorization header Authorization: Bearer eyJhbGci… Cache-Control: max-age=600 Shared cache | Not stored | A shared cache will not store a response to an authenticated request at all unless the response says public, must-revalidate or s-maxage. The browser stores it regardless. |
| No caching headers at all Last-Modified: Mon, 22 Dec 2025 00:00:00 GMT Shared cache | Stored — fresh for 1 day | Saying nothing does not mean "do not cache". With a Last-Modified ten days old, a cache may invent a lifetime of a tenth of that age — a whole day. |
| Vary: * Cache-Control: max-age=600 Vary: * Shared cache | Stored — stale immediately | A stored copy is selected by matching the headers named in Vary. * never matches anything, so the copy is kept and never used. |
| Expires: 0 Expires: 0 Shared cache | Stored — stale immediately | The decades-old way of writing "already expired" does work — but not for the reason it is usually given. This engine hands the value to JavaScript's date parser, which reads 0 as the year 2000 rather than rejecting it, and a date that far in the past is expired either way. A value it genuinely cannot read, such as Expires: never, is also treated as expired. |
| A permanent redirect with no headers HTTP/1.1 301 Shared cache | Stored — stale immediately | A 301 is storable by default, and with nothing to give it a lifetime it is stale at once. Browsers are far stickier about redirects than this; give one an explicit max-age rather than relying on either behaviour. |
| A miscased directive Cache-Control: Max-Age=600 Shared cache | Stored — stale immediately | This engine matches directive names exactly, so it reads nothing here. RFC 9111 makes them case-insensitive and a real cache would honour it — the tool flags the line rather than letting the verdict stand alone. |
Three things this page cannot tell you
- What your CDN actually does. Vendors add their own layer on top: some bypass caching
entirely when a response carries
Set-Cookie, some respect aSurrogate-Controlheader this engine ignores, and most let you override everything in configuration. The headers are the contract; the vendor's documentation is the behaviour. - Whether a stored copy matches a different request. The reuse verdict replays the request
you pasted, so the URL, the method and every header named in
Varynecessarily match. Change one of them and a real cache may well miss. - What happens on revalidation. This answers the storing and freshness questions. It does
not model the 304 exchange, which is where
ETagandLast-Modifiedearn their keep.
About this tool
The decisions are a TypeScript port of http-cache-semantics 4.2.0, pinned by 758 recorded cases — curated ones for every directive, status and header that changes an answer, plus a seeded sweep through combinations nobody thought to curate — generated by running the library itself and replayed against the port on every build. The header parser is ours, and it reports what it made of your paste rather than guessing quietly.
Everything runs in your browser. Headers routinely carry internal hostnames, session cookies and bearer tokens, so nothing you paste is uploaded, logged or sent anywhere — which is also why this tool takes pasted headers instead of fetching a URL for you.
Includes a derivative work of
http-cache-semantics,
Copyright 2016–2018 Kornel Lesiński, licensed under the BSD 2-Clause licence. The upstream licence is kept
in third-party/http-cache-semantics/ in this project's repository.