A cache does not receive a new freshness allowance every time another cache forwards a response. It must account for age already accumulated along the delivery path, then add its own residence time. Otherwise a chain of individually plausible sixty-second caches can keep old content reusable much longer than the origin intended.
Count the age
Age estimates seconds since generation or successful validation, including time in caches and transit. The received Age field is therefore an input to current age, not a counter that stays frozen while the response sits locally. Date supplies the response’s origination timestamp. Clock synchronization and measured request/response timing matter when relating those values.
RFC 9111 describes an apparent age from response receipt minus Date, clamped at zero, and a corrected age value from received Age plus response delay. Its conservative calculation takes the larger value, then adds residence time. For request time 100, receipt 102, Date 95, received Age 10, and current time 107, those values are 7, 12, and finally 17 seconds. Reading only the original Age would miss seven seconds.
Freshness lifetime has a precedence order: shared caches use s-maxage when present; otherwise max-age takes precedence over Expires minus Date. Private caches ignore s-maxage. Without explicit expiration, a permitted heuristic might apply, but its duration is not universally fixed. A response is fresh only while its applicable lifetime is greater than current age; equality is already the stale boundary.
When it breaks
Fresh does not mean usable for every request. Storage permission, URL and Vary matching, and validation requirements still apply. No-cache requires validation before reuse even when an explicit lifetime looks generous. An ETag supplies a validator, not permission to skip validation. Successfully revalidating can update stored metadata without transferring the body; simply receiving a cached response cannot reset its age.
Stale delivery is a separate permission, not an automatic response to an outage. Must-revalidate prohibits reuse once stale until validation succeeds, including when disconnected. Stale-while-revalidate permits a bounded stale interval while validation proceeds, subject to applicable restrictions. It does not turn stale content fresh. RFC 9111 also obsoletes Warning, so absence of the old Warning: 110 field is not evidence of freshness.
Do not confuse storage retention with freshness. Keeping bytes after expiration can be useful for validation or an allowed stale window. A backend’s eviction TTL may therefore exceed the direct-reuse lifetime. If the caller treats that TTL as permission to serve, it can deliver stale responses outside the intended policy.
Check it quickly
Use Node.js and npm; the tested versions are Node 26.0.0 and http-cache-semantics 4.2.0. Install the library below, then execute the JavaScript with NODE_PATH="$freshness_lab/node_modules" node. The test overrides the library’s clock, not the system clock. It uses synthetic metadata, performs no requests, and needs no sleeping; only installation requires the network.
freshness_lab=$(mktemp -d)
npm install --prefix "$freshness_lab" --ignore-scripts --no-audit --no-fund --package-lock=false http-cache-semantics@4.2.0
const assert = require("node:assert/strict");
const CachePolicy = require("http-cache-semantics");
const start = Date.parse("2026-09-07T00:00:00Z");
let clock = start;
class TestPolicy extends CachePolicy {
now() { return clock; }
}
const request = { url: "https://example.test/news", method: "GET", headers: {} };
const headers = {
date: new Date(start).toUTCString(),
age: "20",
expires: new Date(start + 3600000).toUTCString(),
"cache-control": "public, max-age=60, s-maxage=30",
};
const shared = new TestPolicy(request, { headers }, { shared: true });
const single = new TestPolicy(request, { headers }, { shared: false });
assert.equal(shared.maxAge(), 30);
assert.equal(single.maxAge(), 60);
for (const [elapsed, sharedHit, privateHit] of [
[0, true, true], [9, true, true], [10, false, true], [40, false, false],
]) {
clock = start + elapsed * 1000;
assert.equal(shared.age(), 20 + elapsed);
assert.equal(shared.responseHeaders().age, String(20 + elapsed));
assert.equal(shared.satisfiesWithoutRevalidation(request), sharedHit);
assert.equal(single.satisfiesWithoutRevalidation(request), privateHit);
console.log("elapsed:", elapsed, "age:", shared.age(), "reuse:", sharedHit, privateHit);
}
clock = start;
const background = new TestPolicy(request, { headers: {
age: "20", "cache-control": "public, max-age=30, stale-while-revalidate=20",
} });
clock += 15000;
const decision = background.evaluateRequest(request);
assert.equal(background.timeToLive(), 15000);
assert.equal(background.satisfiesWithoutRevalidation(request), false);
assert(decision.response);
assert.equal(decision.revalidation.synchronous, false);
console.log("stale window: retain 15000 ms, serve and revalidate");
clock += 15000;
assert.equal(background.evaluateRequest(request).response, undefined);
assert.equal(background.evaluateRequest(request).revalidation.synchronous, true);
clock = start;
const oldDate = new TestPolicy(request, { headers: {
date: new Date(start - 3600000).toUTCString(),
"cache-control": "public, max-age=60",
} });
assert.equal(oldDate.age(), 0);
assert.equal(oldDate.satisfiesWithoutRevalidation(request), true);
console.log("library limitation: old Date alone does not increase age");
At elapsed times zero, nine, ten, and forty seconds, the calculated ages are twenty, twenty-nine, thirty, and sixty. Both modes initially reuse the response. Shared reuse stops exactly at age thirty; private reuse stops at sixty. The future Expires date does not override either explicit lifetime. Updated outgoing Age is checked as well as the reuse decision.
The separate stale-window fixture reaches age thirty-five, five seconds beyond freshness. Its remaining retention allowance is fifteen thousand milliseconds, but validation is still required. The library permits a response together with asynchronous revalidation; it does not perform that network operation itself. At age fifty, that permission ends and validation becomes synchronous.
The final check exposes an important implementation limit: this library deliberately ignores server Date when calculating age. An hour-old Date with no Age still starts at zero here. Its documented model adds received Age and local residence, not measured response delay or the conservative apparent-age branch above. Consequently, these passing assertions establish the pinned library’s decisions, not complete RFC age-calculation conformance. Test actual intermediary timing and header propagation separately before using a measured cache lifetime as a delivery guarantee.
Sources
- RFC 9111: freshness, lifetime precedence, current age, and stale-response restrictions, checked 2026-09-07.
- MDN: Cache-Control, freshness and stale-while-revalidate directives, checked 2026-09-07.
- http-cache-semantics 4.2.0, retention TTL, revalidation decisions, and the documented server-Date limitation, checked 2026-09-07.