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:
- Cache hit, miss, and bypass mechanics — how caches decide whether a stored response can be used at all.
- Freshness vs validation models explained — the distinction between serving a fresh response directly and issuing a conditional revalidation request.
- Mastering
max-ageands-maxagedirectives — the two primary directives that drive almost every freshness calculation in production.
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:
In plain terms:
- Shared-cache TTL — if the response is being stored by a shared cache (a CDN or a proxy) and
Cache-Controlcontainss-maxage, use that value:freshness_lifetime = s-maxage. - Primary directive — if
Cache-Controlcontainsmax-age, use it:freshness_lifetime = max-age. - Legacy fallback — if neither
s-maxagenormax-ageis present but anExpiresheader exists:freshness_lifetime = Expires − Date(both are HTTP-date strings; the result is in seconds). - Heuristic fallback — if none of the above apply, caches may calculate a heuristic TTL, typically 10 % of the
Last-Modifiedage. 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:
- Open DevTools and select the Network tab. Make sure “Disable cache” is unchecked.
- Reload the page and click the target request.
- In the Headers panel, locate
Cache-Control,Age, andDate. - Check the response status:
200 OK (from disk cache)or200 OK (from memory cache)confirms the browser served a fresh stored copy. A304 Not Modifiedresponse 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:
Ageis a positive integer less thanmax-age(ors-maxagefor CDN responses). IfAgeequals or exceeds the applicable directive value, the response is stale at delivery — the CDN should have revalidated.- The
Dateheader matches the time the origin generated the response, not the current time. IfDatealways 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 see304on every reload, the response is either stale orno-cacheis forcing revalidation on each request regardless of remaining freshness. - CDN-specific headers such as
CF-Cache-Status: HIT(Cloudflare) orX-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 uses-maxageand ignoremax-age; browsers usemax-ageand ignores-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
ETagandLast-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 bystale-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 − Datecan yield a negative value, forcing immediate revalidation on every request. Synchronize all nodes via NTP and usemax-ageinstead ofExpiresto avoid clock-dependent calculations entirely. - CDN-level TTL overrides — Cloudflare Cache Rules and Fastly’s
Surrogate-Controlheader can override thefreshness_lifetimethe 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.
Related
- What happens when
max-ageexpires — a detailed walkthrough of the revalidation flow triggered when the freshness lifetime runs out. - When does a browser invalidate a cached resource — covers the browser-side decision tree that runs once a freshness lifetime expires.
- How to combine
Cache-Controldirectives safely — shows how combinings-maxage,max-age, andstale-while-revalidateaffects the effective freshness for each cache tier. - Cache-Control best practices for REST APIs — applies freshness lifetime design to API responses where stale data carries real consequences.