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:

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 (or X-Cache: HIT) with Age below the max-age value confirms Cache-Control won, regardless of what Expires says.
  • When only Expires is present, the cache’s freshness window matches Expires − Date exactly — verify by comparing the Age at 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 with max-age set shows no such drift.
  • Expires using 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. Sending Expires alongside max-age costs nothing and gives these outliers a usable fallback without affecting modern cache behavior.
  • s-maxage vs Expires for shared cachess-maxage overrides both max-age and Expires specifically for shared caches, per the public vs private cache scope rules — useful when browsers and CDNs need different lifetimes.
  • Date header missing or malformed — if the origin omits Date, a cache receiving an Expires-only response typically stamps its own reception time as Date, which can introduce transport-latency skew into the freshness calculation even without clock drift.
  • Expires: 0 or non-HTTP-date values — some frameworks emit Expires: 0 intending “already expired.” This is not a valid HTTP-date, but RFC-conforming caches must treat any unparseable Expires value 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.


Back to Header Stacking and Directive Precedence