How to Calculate Cache Freshness Lifetime

Engineering teams routinely misconfigure HTTP caching by assuming directives like max-age and Expires are additive or independent. RFC 9111 — which obsoletes RFC 7234 — defines a strict precedence algorithm that collapses all freshness signals into a single definitive freshness_lifetime value. When headers conflict or shared caches apply their own TTL overrides, assets go stale earlier or later than intended, causing unnecessary origin load or serving outdated content.

Prerequisite Concepts

Before working through the algorithm, make sure you are comfortable with three foundational areas:

Step-by-Step Resolution

Step 1 — Extract the caching headers from the response

The freshness calculation depends on four headers: Cache-Control, Expires, Date, and Age. Fetch them with a single curl command:

curl -sI https://example.com/asset.js \
  | grep -iE '(cache-control|age|expires|date|cf-cache-status|x-cache)'

A typical CDN response looks like:

Cache-Control: public, s-maxage=86400, max-age=3600
Age: 14523
Date: Sun, 22 Jun 2026 04:00:00 GMT
CF-Cache-Status: HIT

Record each value. You now have everything needed to run the precedence algorithm.

Step 2 — Apply the RFC 9111 precedence algorithm

RFC 9111 §5.1 resolves freshness_lifetime in strict order. Stop at the first rule that matches:

RFC 9111 Freshness Lifetime Precedence A decision flowchart showing the four-step precedence algorithm: s-maxage for shared caches, then max-age, then Expires minus Date, then heuristic fallback. Response received shared cache AND s-maxage present? Yes s-maxage No max-age present in Cache-Control? Yes max-age No Expires header present? Yes Expires − Date No Heuristic (≈10% of Last-Modified age)

In plain terms:

  1. Shared-cache TTL — if the response is being stored by a shared cache (a CDN or a proxy) and Cache-Control contains s-maxage, use that value: freshness_lifetime = s-maxage.
  2. Primary directive — if Cache-Control contains max-age, use it: freshness_lifetime = max-age.
  3. Legacy fallback — if neither s-maxage nor max-age is present but an Expires header exists: freshness_lifetime = Expires − Date (both are HTTP-date strings; the result is in seconds).
  4. Heuristic fallback — if none of the above apply, caches may calculate a heuristic TTL, typically 10 % of the Last-Modified age. This behaviour is implementation-defined and cannot be predicted reliably; never rely on it in production.

Step 3 — Compute remaining freshness

Once you have freshness_lifetime from step 2, subtract the Age header that accompanies the response from a cache:

remaining_freshness = freshness_lifetime − Age

For the example response in step 1 (s-maxage=86400, Age: 14523):

remaining_freshness = 86400 − 14523 = 71877 seconds (≈ 20 hours)

When remaining_freshness ≤ 0, the stored response is formally stale and requires conditional revalidation before the cache can reuse it — unless stale-while-revalidate or stale-if-error extends the delivery window beyond that point.

Step 4 — Verify the expected cache state

Chrome DevTools — Network tab:

  1. Open DevTools and select the Network tab. Make sure “Disable cache” is unchecked.
  2. Reload the page and click the target request.
  3. In the Headers panel, locate Cache-Control, Age, and Date.
  4. Check the response status: 200 OK (from disk cache) or 200 OK (from memory cache) confirms the browser served a fresh stored copy. A 304 Not Modified response means the browser revalidated after the response went stale, and the server confirmed the cached body is still valid.

curl — repeat requests to confirm Age increments:

# First request — cache is cold or Age is small
curl -sI https://example.com/asset.js | grep -i age

# Wait 10 seconds, repeat
sleep 10
curl -sI https://example.com/asset.js | grep -i age

If Age increments by approximately 10 each time (rather than resetting to 0), the CDN is serving from its cache using the freshness lifetime you set.

Confirm max-age wins over a stale Expires:

Configure your origin to return both Cache-Control: max-age=3600 and an Expires value that is already in the past, then route through a CDN:

curl -sI https://example.com/asset.js \
  | grep -iE '(cache-control|expires|age|cf-cache-status)'

Expected output:

Cache-Control: max-age=3600
Expires: Sat, 21 Jun 2026 12:00:00 GMT
Age: 47
CF-Cache-Status: HIT

The CDN ignores Expires and tracks freshness against max-age=3600 — the directive precedence rules in RFC 9111 mandate this.

Expected Output / Verification

A correctly configured response displays the following characteristics when inspected:

  • Age is a positive integer less than max-age (or s-maxage for CDN responses). If Age equals or exceeds the applicable directive value, the response is stale at delivery — the CDN should have revalidated.
  • The Date header matches the time the origin generated the response, not the current time. If Date always equals the current time, the cache layer is not storing the response.
  • Browser DevTools shows the status as 200 (from disk cache) on repeat loads within the freshness window. If you see 304 on every reload, the response is either stale or no-cache is forcing revalidation on each request regardless of remaining freshness.
  • CDN-specific headers such as CF-Cache-Status: HIT (Cloudflare) or X-Cache: HIT (Varnish / generic) confirm the edge served from its own store.

Edge Cases

  • Browser vs CDN freshness diverge — the CDN and browser split TTL pattern (Cache-Control: public, s-maxage=86400, max-age=3600) is the standard solution: CDNs use s-maxage and ignore max-age; browsers use max-age and ignore s-maxage. Both freshness lifetimes are calculated independently.
  • HTTP/1.1 vs HTTP/2 — no change to freshness semantics — RFC 9111 freshness rules apply uniformly regardless of protocol version. HTTP/2 multiplexing changes transport, not cache semantics.
  • Missing validators — if the origin omits both ETag and Last-Modified, stale responses cannot be revalidated with a conditional request. The cache must either discard the response and fetch a new copy unconditionally, or serve it stale if permitted by stale-while-revalidate. Always emit at least one validator on cacheable responses.
  • Clock skew with Expires — when the origin clock and CDN clock are unsynchronized, Expires − Date can yield a negative value, forcing immediate revalidation on every request. Synchronize all nodes via NTP and use max-age instead of Expires to avoid clock-dependent calculations entirely.
  • CDN-level TTL overrides — Cloudflare Cache Rules and Fastly’s Surrogate-Control header can override the freshness_lifetime the origin sets. When debugging unexpected freshness behaviour, inspect the CDN configuration for platform-level overrides after verifying that origin headers are correct.

Frequently Asked Questions

Does max-age override Expires?

Yes. RFC 9111 mandates that max-age takes precedence over Expires when both are present in the same response. The Expires header is treated as a legacy fallback only when neither max-age nor s-maxage appears in Cache-Control.

What does the Age header represent?

The Age header reports how many seconds have elapsed since the response was stored or last revalidated in a shared cache. A CDN that has cached a response for 4 hours will deliver it with Age: 14400. Subtracting Age from freshness_lifetime gives remaining freshness at the moment of delivery.

What happens when remaining freshness reaches zero?

The stored response becomes formally stale. Caches must revalidate before reuse — unless stale-while-revalidate permits background revalidation while still serving the stale copy, or stale-if-error permits serving stale when the origin returns an error.

Can CDN overrides change the freshness lifetime?

Yes. CDN-level cache rules can override origin Cache-Control TTLs. When debugging, always check for platform overrides after confirming origin headers.


Back to Cache Hit, Miss, and Bypass Mechanics