Cache-Control vs Expires: Which Wins?
It’s common to find both Cache-Control and Expires set on the same response — often because a framework or CDN adds one automatically while a hand-written header adds the other. When their effective lifetimes disagree, only one directive can actually govern cache behavior. RFC 9111 §5.3 settles this unambiguously: Cache-Control’s max-age (and, for shared caches, s-maxage) always wins. Expires is a legacy holdover from HTTP/1.0, and any cache that still honors it over a present max-age is not conforming to the current specification.
Prerequisite Concepts
Before comparing the two headers directly, it helps to understand:
- Mastering
max-ageands-maxagedirectives — the directives that take precedence overExpiresin every conforming cache. - How to calculate cache freshness lifetime — the full RFC 9111 §5.1 precedence algorithm, of which the
Cache-Control-over-Expiresrule is one step. - What happens when
max-ageexpires — the revalidation flow triggered once the winning directive’s freshness lifetime runs out.
Comparison Table
Cache-Control: max-age / s-maxage |
Expires |
|
|---|---|---|
| Format | Relative offset in seconds from response time | Absolute HTTP-date (Sun, 06 Jul 2026 12:00:00 GMT) |
| Clock dependency | None — computed from elapsed seconds | Requires cache clock and Date header to agree |
| Precedence (RFC 9111 §5.3) | Wins whenever present | Used only if max-age/s-maxage absent |
| Shared-cache override | s-maxage overrides max-age for shared caches |
No equivalent shared-cache variant |
| Introduced by | HTTP/1.1 (RFC 9111, obsoleting RFC 7234/2616) | HTTP/1.0 |
| Recommended usage today | Primary mechanism — always set | Fallback only, for pre-HTTP/1.1 clients/proxies |
Step-by-Step Resolution
Step 1 — Send both headers with conflicting values
Configure the origin to emit a fresh max-age alongside an Expires value already in the past:
HTTP/1.1 200 OK
Cache-Control: max-age=3600
Expires: Sat, 01 Jan 2000 00:00:00 GMT
Date: Mon, 06 Jul 2026 12:00:00 GMT
ETag: "v3-report"
Taken literally, Expires says this response expired over two decades ago. Cache-Control says it’s good for another hour. These directly contradict each other.
Step 2 — Confirm the cache follows max-age, not Expires
Request the resource twice through a shared cache, a few seconds apart:
curl -sI https://example.com/report.json | grep -iE '(cache-control|expires|age|cf-cache-status)'
sleep 5
curl -sI https://example.com/report.json | grep -iE '(cache-control|expires|age|cf-cache-status)'
Expected output on the second request:
Cache-Control: max-age=3600
Expires: Sat, 01 Jan 2000 00:00:00 GMT
Age: 5
CF-Cache-Status: HIT
CF-Cache-Status: HIT with a small, incrementing Age confirms the cache served the stored response instead of treating it as already-expired. The directive precedence rules governing this page mandate exactly this outcome — a present max-age makes the Expires value irrelevant, no matter how far in the past it is.
Step 3 — Remove Cache-Control and observe Expires take over
Now strip max-age and s-maxage entirely, leaving only Expires and Date:
HTTP/1.1 200 OK
Expires: Mon, 06 Jul 2026 13:00:00 GMT
Date: Mon, 06 Jul 2026 12:00:00 GMT
ETag: "v3-report"
With no Cache-Control freshness directive present, RFC 9111 §5.1 falls through to Expires − Date:
curl -sI https://example.com/legacy-report.json | grep -iE '(expires|date|age)'
freshness_lifetime = Expires − Date = 13:00:00 − 12:00:00 = 3600 seconds
Only in this configuration — Cache-Control absent — does Expires actually govern behavior.
Step 4 — Reproduce the clock-skew failure
Keep the Expires-only configuration from Step 3, but introduce a one-hour clock skew on the cache node (simulating an unsynchronized NTP source):
# Cache node's clock reports 13:30, but Date on the response says 12:00
curl -sI https://example.com/legacy-report.json | grep -iE '(expires|date|age)'
Because the cache computes freshness against its own clock rather than trusting Date blindly in some implementations, or because Date itself was stamped by a skewed origin, the effective Expires − Date arithmetic can silently shrink to zero or go negative — forcing revalidation on every single request even though the content hasn’t changed. Repeat the same test with Cache-Control: max-age=3600 restored and no clock skew reproduces the problem: max-age is a relative offset, immune to any clock disagreement between origin and cache.
Expected Output / Verification
- When both headers are present,
CF-Cache-Status: HIT(orX-Cache: HIT) withAgebelow themax-agevalue confirmsCache-Controlwon, regardless of whatExpiressays. - When only
Expiresis present, the cache’s freshness window matchesExpires − Dateexactly — verify by comparing theAgeat which the cache begins issuing conditional requests to the origin. - A clock-skew reproduction on an
Expires-only response shows revalidation happening far earlier (or later) than the nominal one-hour window suggests. The identical test withmax-ageset shows no such drift. Expiresusing any format other than a valid HTTP-date (per RFC 9110 §5.6.7) must be treated by the cache as already expired — verify malformed dates fail closed rather than being silently ignored.
Edge Cases
- Ancient proxies and HTTP/1.0 clients — a small number of legacy caching proxies never learned
Cache-Control. SendingExpiresalongsidemax-agecosts nothing and gives these outliers a usable fallback without affecting modern cache behavior. s-maxagevsExpiresfor shared caches —s-maxageoverrides bothmax-ageandExpiresspecifically for shared caches, per the public vs private cache scope rules — useful when browsers and CDNs need different lifetimes.Dateheader missing or malformed — if the origin omitsDate, a cache receiving anExpires-only response typically stamps its own reception time asDate, which can introduce transport-latency skew into the freshness calculation even without clock drift.Expires: 0or non-HTTP-date values — some frameworks emitExpires: 0intending “already expired.” This is not a valid HTTP-date, but RFC-conforming caches must treat any unparseableExpiresvalue as equivalent to an already-past date, so the practical effect is usually correct by accident.
Frequently Asked Questions
Does Expires still matter if I set max-age?
Only as a fallback for HTTP/1.0-era caches that don’t understand Cache-Control at all. Any RFC 9111-conforming cache ignores Expires whenever max-age or s-maxage is present in the same response.
Why is Expires considered risky compared to max-age?
Expires is an absolute HTTP-date, so its effective freshness depends on the cache’s clock matching the origin’s. Any clock skew between the two directly distorts the computed freshness lifetime. max-age is a relative offset in seconds and is immune to clock skew entirely.
What format must the Expires header use?
Expires must use the HTTP-date format defined in RFC 9110 §5.6.7, for example Sun, 06 Jul 2026 12:00:00 GMT. Any other format, or the literal value 0, must be treated by the cache as already expired.
Should I stop sending Expires entirely?
Not necessarily. Sending both costs nothing and protects against the rare legacy proxy or client that predates Cache-Control support. The rule is to never rely on Expires alone for anything that matters, and to always let max-age or s-maxage be the header that actually governs behavior.
Related
- How to combine
Cache-Controldirectives safely — shows howmax-age,s-maxage, and other directives interact onceExpiresis out of the picture. - Cache-Control best practices for REST APIs — applies these precedence rules to endpoints where a stale response has real consequences.
- How must-revalidate interacts with max-age — covers what happens after the winning freshness lifetime, whichever header set it, actually expires.