HTTP defines both the `Expires` header and `Cache-Control: max-age`. If a response carries both, which one wins, and why does `Expires: 0` appear so often in real responses?
answer
- s-maxage > max-age > Expires > heuristic
- relative time beats absolute — no clock comparison
- lifetime = Expires - Date, not Expires - now
- Expires: 0 / -1 / unparsable = already stale
- Pragma: no-cache as a response header does nothing
basics
~20 sCache-Control: max-age wins for any cache that understands HTTP/1.1 — essentially all of them. Expires is the HTTP/1.0 absolute-timestamp form and survives only as a fallback. Expires: 0 is an invalid date, which caches must treat as already expired, so it is a compact "already stale" marker for ancient caches.
solid answer
~50 sThey express the same idea two ways. **`Expires`** is an absolute HTTP-date at which the response becomes stale; **`Cache-Control: max-age=N`** is a relative lifetime in seconds counted from when the origin generated the response. When both are present, **`max-age` takes precedence** in any cache that understands `Cache-Control`, and `Expires` is ignored. In a shared cache, `s-maxage` outranks both. The ordering exists because relative time is robust and absolute time is not: `Expires` compares the origin's clock to the cache's clock, so any skew silently shifts the lifetime, whereas `max-age` needs only elapsed-time arithmetic. `Expires: 0` (and any unparsable date) must be treated as a time already in the past, so the response is stale on arrival. It is shorthand for "do not reuse this without checking", aimed at HTTP/1.0-era caches that predate `Cache-Control`. Today the correct expression is `Cache-Control: no-cache` or `no-store`, and `Expires: 0` alongside it is belt-and-braces for intermediaries almost nobody still runs.
code
http · 6 linesHTTP/1.1 200 OK
Date: Mon, 12 Aug 2026 10:00:00 GMT
Expires: Mon, 12 Aug 2026 11:00:00 GMT
Cache-Control: max-age=60
# fresh for 60 seconds, not one hourgo deeper
Know that max-age wins over Expires and that Expires: 0 means already stale.
State the full precedence order and explain why a relative lifetime is more robust than an absolute timestamp.
Discuss clock-skew failure modes, why the lifetime is Expires minus Date, and why the legacy defensive header block is redundant today.
Standardise on Cache-Control-only policies across services and treat legacy headers as noise to be removed rather than replicated.
## Two generations of the same idea HTTP/1.0 had one expiry mechanism: `Expires`, an absolute HTTP-date after which a stored response must not be reused without validation. HTTP/1.1 introduced `Cache-Control` and with it `max-age`, a relative lifetime in seconds. Both survive, and the rule is simple: **`Cache-Control` overrides `Expires` wherever it is understood.** The full precedence order for computing a freshness lifetime is: 1. `Cache-Control: s-maxage` — shared caches only. 2. `Cache-Control: max-age`. 3. `Expires` minus `Date`. 4. A heuristic derived by the cache, if none of the above is present. ## Why relative beat absolute `Expires` requires the cache to compare a timestamp written by the origin against its own clock. Any disagreement between the two clocks distorts the lifetime directly: an origin running five minutes fast grants five extra minutes of freshness to every cache on earth; one running slow expires content early. Caches partially defend against this by computing the lifetime as `Expires - Date` rather than `Expires - now` — using the origin's own `Date` header keeps both endpoints on the same clock — but that only works when `Date` is present and correct. `max-age` sidesteps the problem: the cache measures elapsed time locally and adds the reported `Age`, so no cross-machine clock comparison is needed. It also composes cleanly across hops, which absolute timestamps do not. ## Invalid dates mean expired RFC 9111 requires a cache to treat an `Expires` value it cannot parse — including `0`, which is not an HTTP-date at all — as a time in the past. That is what makes `Expires: 0` work as an idiom. Other forms you will see are `Expires: -1` and an explicit past date such as `Expires: Thu, 01 Jan 1970 00:00:00 GMT`. All three mean the same thing: stale immediately. The reason to write it deliberately, rather than by accident, is defence against pre-HTTP/1.1 intermediaries. In practice those are essentially extinct, so the modern equivalent of "do not reuse without checking" is `Cache-Control: no-cache`, and "do not store at all" is `Cache-Control: no-store`. Frameworks still emit the combination `Cache-Control: no-cache, no-store, must-revalidate` plus `Pragma: no-cache` plus `Expires: 0` as a defensive block; it is redundant but harmless. Note a related trap: `Pragma: no-cache` is an HTTP/1.0 *request* directive. As a response header it has no defined meaning in RFC 9111, which deprecates it, and modern caches ignore it there. Relying on it to prevent response caching does nothing. ## When Expires is still worth sending Rarely, and only as a companion. If you have a hard calendar deadline — an embargoed announcement, a scheduled price change, a token that is invalid after a specific instant — `Expires` expresses that intent directly and readably, but you should still send `max-age` computed from the same instant, because `max-age` is what will actually be obeyed. Sending `Expires` alone means the lifetime is at the mercy of clock agreement. The inverse mistake is more common: teams set a far-future `Expires` for asset caching without a `max-age`, then find behaviour varies across intermediaries, or that the date passes and everything silently becomes uncacheable on a specific day. A relative `max-age` never has a cliff edge on a calendar date. ## What to remember when reading a response Scan for `Cache-Control` first. If it contains `s-maxage` or `max-age`, `Expires` is decoration and you can ignore it. If `Cache-Control` is absent or carries no lifetime directive, then `Expires - Date` is the lifetime. If neither is present, the cache is free to invent a heuristic lifetime, which is where surprises come from. And if `Expires` holds `0`, `-1` or garbage, the response is stale from the moment it arrives — which is deliberate, not a bug.
- Why do caches compute the lifetime as `Expires - Date` rather than `Expires - now`?Both `Expires` and `Date` are written by the origin, so subtracting them yields a duration measured entirely on the origin's clock and is immune to disagreement with the cache's clock. Using local time instead would fold any skew straight into the lifetime. It is a way of recovering a relative duration from an absolute timestamp.
- Does sending `Pragma: no-cache` on a response prevent caching?No. `Pragma` was defined as an HTTP/1.0 request directive and has no specified meaning as a response header; RFC 9111 deprecates it entirely. Modern caches ignore it in responses, so anything relying on it for correctness is relying on nothing. Use `Cache-Control: no-cache` or `no-store` instead.
- Is there any case where you would send only `Expires` and no `max-age`?Practically none. Even for a genuine calendar deadline, you should send both — `Expires` to express the intent and `max-age` computed to the same instant, because `max-age` is the value caches will actually obey. Sending `Expires` alone leaves the effective lifetime dependent on clock agreement and on the cache being willing to fall back that far.
saying these in an interview costs you the question
- Believing `Expires` overrides `max-age` because it is more specific
- Setting a far-future `Expires` with no max-age and expecting reliable long-term caching
- Thinking `Expires: 0` is invalid and ignored rather than meaning already expired
- Expecting `Pragma: no-cache` in a response to stop caching
- Forgetting that `s-maxage` outranks both in shared caches