Age is an estimate of elapsed seconds since an origin generated or successfully validated a response. It includes time in caches and in transit. It is not the object’s creation timestamp, a count of requests, or simply the time spent on the last proxy’s disk. Reading it correctly requires the response’s freshness policy and the request context.
Combine estimates without counting time twice
RFC 9111 defines two age estimates at receipt. Apparent age compares the receiving cache’s clock with Date, clamping a negative result to zero. Corrected age starts with the received Age value and adds the interval between sending the request and receiving the response. A missing Age supplies zero to this calculation; it does not erase the Date-based estimate.
The RFC permits using corrected age alone where the cache chain supports the relevant HTTP behavior. Its more conservative calculation takes the maximum of apparent age and corrected age, then adds local residence time. The exercise uses that conservative option. Adding both estimates would count overlapping time twice, while resetting Age to zero at every cache hop would lose upstream history.
Receipt time, request time, and elapsed residence must be measured consistently. Date comparison also depends on clock synchronization, so the result remains an estimate rather than an exact stopwatch measurement. Production implementations need appropriate clock handling and integer serialization. The small calculation below uses fixed whole-second UTC timestamps and valid header values, not a complete HTTP cache implementation.
Freshness is a separate decision
A current age of 45 seconds is fresh under a 60-second lifetime, but stale under a 30-second lifetime. Equality is already stale: freshness requires age to be less than the lifetime. This comparison does not independently authorize reuse. Request restrictions, cache keys, Vary, storage rules, and directives requiring validation still matter even for a young response.
For a shared cache, s-maxage overrides max-age; its value can shorten or lengthen the applicable lifetime. Stale-while-revalidate does not make an old response fresh. It permits specified stale reuse while validation happens, subject to the policy and implementation. Treat allowed stale service as a distinct state, rather than extending the freshness label until every grace period is exhausted.
When it breaks
A large Age is not automatically evidence of a broken deployment. A year-lived, fingerprinted asset can be old and still fresh. An unchanged representation can also be successfully validated later, refreshing its metadata without replacing its bytes. Age then describes the newer validation history, not necessarily the time when those bytes were first authored.
Nor must Age increase monotonically across every request to one URL. Requests may reach different edge locations, select different representations, encounter an eviction, or follow successful revalidation. Age zero can occur for a newly cached response, and an absent header alone does not establish that the origin answered directly. Compare matching requests, body identity, validators, Date, policy, and provider diagnostics together.
If a response is incorrectly reused, first distinguish wrong identity from wrong lifetime. A perfect age calculation cannot repair a currency or authorization value omitted from the cache key. Likewise, shortening origin max-age does not retroactively inform already-fresh copies. Inspect the headers on the actual GET path; a HEAD probe can have different implementation behavior and does not show which body was delivered.
Check it quickly
Run this block with Python 3.11 or newer; it was executed with Python 3.14.4. It needs only the standard library. HTTP dates are parsed and formatted by email.utils, and all timestamps are synthetic. No network requests are made, no system clock changes are needed, and no sleeping is involved. The input timestamps are ordered and the Age inputs are valid nonnegative integers.
from datetime import timedelta
from email.utils import format_datetime, parsedate_to_datetime
base = parsedate_to_datetime("Thu, 01 Jan 2026 00:00:00 GMT")
def at(seconds):
return base + timedelta(seconds=seconds)
def current_age(headers, request_time, response_time, now):
date_value = parsedate_to_datetime(headers["Date"])
age_value = int(headers.get("Age", "0"))
assert request_time <= response_time <= now and age_value >= 0
apparent_age = max(0, (response_time - date_value).total_seconds())
response_delay = (response_time - request_time).total_seconds()
corrected_age_value = age_value + response_delay
corrected_initial_age = max(apparent_age, corrected_age_value)
resident_time = (now - response_time).total_seconds()
return corrected_initial_age + resident_time
def headers(date_seconds, age=None):
result = {"Date": format_datetime(at(date_seconds), usegmt=True)}
if age is not None:
result["Age"] = str(age)
return result
cases = [
("Date estimate dominates", headers(0, 5), 10, 12, 20, 20),
("upstream Age dominates", headers(0, 50), 10, 12, 20, 60),
("future Date is clamped", headers(30, 5), 10, 12, 20, 15),
("Age absent means zero input", headers(0), 10, 12, 20, 20),
("after modeled validation", headers(100, 0), 100, 102, 105, 5),
]
for name, fields, sent, received, now, expected in cases:
age = current_age(fields, at(sent), at(received), at(now))
assert age == expected
print(f"{name}: {age:g} seconds")
freshness_lifetime = 60
assert 45 < freshness_lifetime
assert not 60 < freshness_lifetime
print("lifetime=60: age 45 is fresh; age 60 is stale")
With receipt at second twelve and inspection at second twenty, the first case returns twenty, not twenty-seven: the Date-based and corrected estimates overlap. Raising upstream Age to fifty produces sixty after request delay and residence are included. A future Date contributes zero apparent age, but the upstream age and elapsed intervals still produce fifteen.
The missing-Age case still obtains twenty from Date. The modeled successful validation uses new response metadata and yields five, rather than the 105 seconds since the earlier generation timestamp. Finally, the lifetime assertions distinguish fresh age 45 from stale age 60. These results verify the stated arithmetic and boundary, not a particular browser’s or CDN’s header generation, routing, or malformed-field handling.
Further reading
- Content-Type should describe the bytes you actually send
- Cache headers are a contract with two clocks
Sources
- RFC 9111: calculating age, independent estimates, response delay, residence, and conservative correction, checked 2026-09-07.
- MDN: Age, header syntax and nonnegative seconds, checked 2026-09-07.