HTTP cache tester: will this response be cached?

Paste the headers you are about to ship, or the ones you just pulled out of a log, and pick the cache you care about. You get four answers — stored, how long it stays fresh, whether the next request avoids the origin, and which line decided each one. Paste a second, different request and you get a fifth: whether it shares the same cache entry, or quietly doubles your traffic to the origin.

Runs entirely in your browser. Your data is never uploaded.

A status line is optional — without one the response is read as a 200. HTTP/2 pseudo-headers (:status) work, so a paste straight out of your browser's network panel is fine.

Only the method and two headers change whether the response is stored — Authorization and Cache-Control — but the rest does no harm, and the request line and every header named in Vary matter to the box below.

Paste a different request to ask whether it finds the copy stored above, or leave this empty. The target, Host, the method and every header named in the response's Vary all have to match — and they are compared as text, so gzip, br and br, gzip are two separate entries.

Which cache?
Examples

    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

    1. May I store this at all? The request method, the status code, no-store, private and an Authorization request header all decide this before any lifetime is worked out.
    2. How long does it stay fresh? s-maxage beats max-age beats Expires — but only for a shared cache. With none of them, a cache may guess from Last-Modified.
    3. How old is it already? The Age header counts time the response spent in caches upstream, and it is subtracted from the lifetime before anything else happens.
    4. Is this request even asking for the stored copy? The target, the Host, the method and every header the response's Vary names have to match the request that produced it. This is the question the second request box answers, and it is asked before freshness.
    5. Can I answer this request from the copy? Freshness is necessary and not sufficient: no-cache, must-revalidate and 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.

    Whether two requests share one cache entry

    A cache does not look up a URL, it looks up a key: the request target, the host, the method, and then every header the stored response's Vary nominated. Miss any part of that and the cache has no copy of your response — not a stale copy, no copy — so the origin sends the whole body back. It is a different failure from "the copy expired", and tuning max-age does nothing for it.

    The trap is that this engine compares the nominated headers as text. Nothing is re-ordered, case-folded or re-parsed, so two requests a server would answer identically can still get two entries. Every row below is this page's own answer, computed when the page was built and asserted by a test. The response is the same each time — Date: Thu, 01 Jan 2026 00:00:00 GMT, Cache-Control: max-age=600, and the Vary in the first column — so the only thing that moves is the pair of requests.

    Response The two requests Result Why it catches people
    Vary: Accept-Encoding Accept-Encoding storedgzip, br laterbr, gzip Separate entry — the origin is asked The same two codings in the other order. The comparison is on the text of the value, not on what it means, so this is a second cache entry and a second trip to your origin.
    Vary: Accept-Encoding Accept-Encoding storedgzip laterGZIP Separate entry — the origin is asked Coding names are case-insensitive to a server and case-sensitive to this cache key. One client spelling it differently halves your hit ratio.
    Vary: Accept-Encoding Accept-Encoding storedgzip, deflate, br latergzip, deflate, br, zstd Separate entry — the origin is asked A browser release that adds one coding to the list invalidates nothing and matches nothing: the old entry stays, unused, until it expires. This is the ordinary reason a CDN hit ratio drops after a Chrome update.
    Vary: Accept-Encoding Accept-Encoding stored(not sent) later(sent, empty) Separate entry — the origin is asked Sending the header empty is not the same as not sending it. Absent matches absent; absent never matches present, however empty the present one is.
    Vary: Accept-Language Accept-Language storeden-GB,en;q=0.9 lateren-GB,en;q=0.9 Same entry — served from the cache The ordinary case, for contrast: byte-identical values match, and the copy is served with no request to the origin.
    Vary: Cookie Cookie storedsession=1; theme=dark latersession=2; theme=dark Separate entry — the origin is asked Vary: Cookie means one cache entry per distinct cookie header. With a session cookie in play that is one entry per visitor, which is a cache that stores a great deal and serves almost nothing.
    Vary: * Anything stored(not sent) later(not sent) Separate entry — the origin is asked The documented meaning: a stored copy is never selected for any request, so the response is effectively uncacheable however long it stays fresh.
    Vary: *, Accept Accept storedtext/html latertext/html Same entry — served from the cache Here the star is one name in a list, and this engine only applies the never-match rule when the whole value is "*". The star becomes a header name absent from both requests, so it matches — the opposite of what RFC 9111 asks for. The page says so under the verdict.
    Vary: Accept-Encoding The request target stored/index.html later/index.html?v=2 Separate entry — the origin is asked The target is compared before any header, and a query string is part of it. A cache-busting parameter does not refresh an entry, it creates a new one.
    (no Vary header) The method storedGET laterHEAD Separate entry — the origin is asked RFC 9111 lets a cache answer a HEAD from a stored GET, and most CDNs do. This engine does not — and its own revalidation path runs the same comparison with HEAD allowed, so the two halves of the library disagree.
    (no Vary header) Host storedexample.com later(not sent) Separate entry — the origin is asked Host is compared as pasted. Leaving it out of the second block is a different entry, not a wildcard — worth knowing before reading anything into a mismatch here.

    Two rows are places where this engine is not RFC 9111, and the page says so under the verdict whenever your own headers reach one. Vary: * stops meaning "never reuse" the moment it shares the header with anything else, a single space included, because the never-match rule is a comparison against the whole field value. And a HEAD is never answered from a stored GET, although RFC 9111 allows it and most CDNs do it — the library's own revalidation path runs the same comparison with HEAD permitted, so its two halves disagree with each other.

    Three things this page cannot tell you

    About this tool

    The decisions are a TypeScript port of http-cache-semantics 4.2.0, pinned by 1,408 recorded cases — 758 for storing and freshness and 650 for whether a second request shares the entry, curated ones for every directive, status and header that changes an answer plus seeded sweeps through the 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.