The Cache Entry Needs a Witness
A cache hit looks like nothing happened.
That is the trick and the danger. The browser asked for /article, the cache
returned bytes it already had, and the origin server never entered the room.
The missing question is:
why was that legal?
HTTP caching has two different answers.
freshness: this stored response is young enough to reuse without asking
validation: this stored response has a witness; ask whether the witness still matches
Confusing those two is how “cache it for five minutes” turns into stale UI, unnecessary origin traffic, or a thundering herd at expiration.
Fresh Is Not The Same As Current
RFC 9111 defines HTTP caching. Its overview is worth reading literally: a cache can reuse a fresh response without validation, while a response that is not fresh might still be reusable after validation, or in some stale-serving cases when the origin is unavailable.1
That means Cache-Control: max-age=60 is not a claim that the origin will stay
unchanged for 60 seconds. It is permission to reuse the stored representation
for 60 seconds.
origin changes at t=10
cache fetched at t=0 with max-age=60
request at t=30
The cache may still return the t=0 body. It is fresh by the protocol rule even
though it is not current relative to the origin. This is not a bug. It is the
latency and bandwidth bargain you chose.
If that bargain is unacceptable, shorten freshness, use no-cache so reuse
requires validation, or change the URL when the content changes. Do not expect a
freshness lifetime to behave like a subscription to origin state.
Validators Make The Question Cheap
An entity tag is a witness attached to a representation:
ETag: "v42"
Later, a cache can ask:
If-None-Match: "v42"
If the current representation still matches, the origin can answer:
304 Not Modified
No response body needs to cross the wire. The cache keeps its stored body and freshens the metadata.
RFC 9110 calls out why entity tags are often better validators than modification dates: a date can be inconvenient to maintain, has one-second resolution in HTTP date values, and might not be consistently maintained across systems.2 This is the quiet engineering reason ETags matter. They are not decorative hashes in a response header. They are a compact way to say:
the body you have is still the body I would send
Last-Modified can be useful. It is often enough for static files. But if a
build pipeline can update multiple times inside one second, or if several origin
nodes disagree about file timestamps, time is a blurry witness.
Lab: Five Origin Trips, Two Bodies Saved
The lab below simulates one URL, one origin, and four client-visible policies:
always fetch send a full GET every time
expiry only reuse while fresh; fetch full body when expired
ETag validate reuse while fresh; send If-None-Match when expired
SWR serve stale during a stale-while-revalidate window
The default origin changes at 9s and 19s. Requests keep arriving. The cache
uses:
Cache-Control: max-age=6, stale-while-revalidate=8
origin latency: 420 ms
body size: 40 KB
In that default run:
| Policy | Origin trips | Full bodies | 304s | Bytes from origin | Client blocking |
|---|---|---|---|---|---|
| always fetch | 35 | 35 | 0 | 1,424.5 KB | 14.7 s |
| expiry only | 5 | 5 | 0 | 203.5 KB | 2.1 s |
| ETag validate | 5 | 3 | 2 | 123.5 KB | 2.1 s |
| stale-while-revalidate | 5 | 3 | 2 | 123.5 KB | 0.42 s |
The validator does not reduce the number of origin trips in this trace. It reduces the amount of origin data sent during the trips that discover nothing changed. The stale-while-revalidate policy does something different: it reduces client blocking by serving a stale response immediately while validation happens in the background.
The origin changes twice while clients keep asking for the same URL.
The 304 Is Not A Cache Hit
A 304 Not Modified is an origin response. It still costs a round trip and
server work. It is cheaper than a 200 OK with a body, but it is not the same
as a fresh cache hit.
This distinction matters in incident analysis. Suppose origin traffic drops after adding validators. Did the cache serve more hits, or did the same number of requests reach the origin with smaller responses? Those are different wins.
The lab’s default makes that separation visible:
const lab = require("./assets/js/http-cache-validator-lab.js");
const result = lab.evaluate({
scenario: "release",
maxAge: 6,
swr: 8,
latency: 420,
cadence: 1.1
});
console.log(result.policies.validator.summary);
The validator policy performs five origin requests, just like the expiry-only
policy. Two of them are 304, so it sends 123.5 KB from origin instead of
203.5 KB. That is a body-byte win, not an origin-trip win.
The exported audit checks 108 scenario/parameter combinations with six obligations per case:
each policy returns one response per request
response rows have valid waits and versions
304 responses occur only when the validator matches the current ETag
validator byte count never exceeds expiry-only byte count
SWR stale responses are served only inside the configured SWR window
SWR never increases max blocking wait relative to ordinary validation
Run it directly:
node - <<'NODE'
const lab = require("./assets/js/http-cache-validator-lab.js");
const EXPECTED_SCENARIOS = ["release", "newsroom", "outage", "quiet"];
const EXPECTED_MAX_AGES = [2, 6, 12];
const EXPECTED_SWRS = [0, 6, 14];
const EXPECTED_LATENCIES = [120, 420, 900];
const EXPECTED_CHECK_NAMES = [
"each policy returns one response per request",
"response rows have valid waits and versions",
"304 responses carry matching current validators",
"validator byte count never exceeds expiry-only cache",
"SWR stale responses stay inside the SWR window",
"SWR never increases max blocking wait"
];
const EXPECTED_CHECK_SHAPE = EXPECTED_CHECK_NAMES.map((name) => ({
name,
ok: true
}));
const EXPECTED_BY_SCENARIO = [
"release:27 cases/162 checks:81/468/7.200/0:162",
"newsroom:27 cases/162 checks:42/696/5.400/0:129",
"outage:27 cases/162 checks:66/375/6.300/11:369",
"quiet:27 cases/162 checks:126/195/6.300/0:207"
];
const EXPECTED_BY_MAX_AGE = [
"2:36 cases/216 checks:252/555/7.200/10:4997100/15077100",
"6:36 cases/216 checks:54/488/4.500/10:4719600/6879600",
"12:36 cases/216 checks:9/691/2.700/11:3730200/4090200"
];
const EXPECTED_BY_SWR = [
"0:36 cases/216 checks:105/392/0.000/11:4482300/8682300",
"6:36 cases/216 checks:105/671/7.200/0:4482300/8682300",
"14:36 cases/216 checks:105/671/7.200/0:4482300/8682300"
];
const EXPECTED_BY_LATENCY = [
"120:36 cases/216 checks:135/617/1.800/11:4433100/9833100",
"420:36 cases/216 checks:105/579/5.880/9:4525800/8725800",
"900:36 cases/216 checks:75/538/7.200/9:4488000/7488000"
];
const EXPECTED_CASE_KEYS = EXPECTED_SCENARIOS.flatMap((scenario) =>
EXPECTED_MAX_AGES.flatMap((maxAge) =>
EXPECTED_SWRS.flatMap((swr) =>
EXPECTED_LATENCIES.map((latency) => `${scenario}/${maxAge}/${swr}/${latency}`)
)
)
);
const EXPECTED_CASES = EXPECTED_CASE_KEYS.length;
const EXPECTED_CHECKS = EXPECTED_CASES * EXPECTED_CHECK_NAMES.length;
const EXPECTED_TOTALS = {
checked: EXPECTED_CASES,
passed: EXPECTED_CASES,
total: EXPECTED_CASES,
passedChecks: EXPECTED_CHECKS,
totalChecks: EXPECTED_CHECKS
};
function sameList(left, right) {
return left.length === right.length &&
left.every((value, index) => value === right[index]);
}
function sameJson(left, right) {
return JSON.stringify(left) === JSON.stringify(right);
}
function groupByAxis(cases, field) {
const groups = new Map();
for (const row of cases) {
const key = row[field];
if (!groups.has(key)) {
groups.set(key, {
key,
cases: 0,
maxBlockingSaved: 0,
maxSwrErrors: 0,
passed: 0,
passedChecks: 0,
swrStale: 0,
totalChecks: 0,
ttlBytes: 0,
validator304: 0,
validatorBytes: 0,
validatorOriginCalls: 0
});
}
const group = groups.get(key);
group.cases += 1;
group.maxBlockingSaved = Math.max(group.maxBlockingSaved, row.blockingSaved);
group.maxSwrErrors = Math.max(group.maxSwrErrors, row.swrErrors);
if (row.passed) group.passed += 1;
group.passedChecks += row.passedChecks;
group.swrStale += row.swrStale;
group.totalChecks += row.totalChecks;
group.ttlBytes += row.ttlBytes;
group.validator304 += row.validator304;
group.validatorBytes += row.validatorBytes;
group.validatorOriginCalls += row.validatorOriginCalls;
}
return Array.from(groups.values()).sort((left, right) =>
String(left.key).localeCompare(String(right.key), undefined, { numeric: true })
);
}
function aggregateShape(row) {
return `${row.key}:${row.cases} cases/${row.totalChecks} checks:` +
`${row.validator304}/${row.swrStale}/` +
`${row.maxBlockingSaved.toFixed(3)}/${row.maxSwrErrors}:` +
`${row.validatorBytes}/${row.ttlBytes}`;
}
function auditShape(audit) {
return {
totals: {
checked: audit.checked,
passed: audit.passed,
total: audit.total,
passedChecks: audit.passedChecks,
totalChecks: audit.totalChecks
},
cases: audit.cases.map(
(row) => `${row.scenario}/${row.maxAge}/${row.swr}/${row.latency}`
),
checksByCase: audit.cases.map((row) =>
lab.evaluate({
scenario: row.scenario,
maxAge: row.maxAge,
swr: row.swr,
latency: row.latency
}).audit.checks.map((check) => ({
name: check.name,
ok: check.ok
}))
),
byScenario: audit.byScenario.map(
(row) => `${row.scenario}:${row.cases} cases/${row.totalChecks} checks:` +
`${row.validator304}/${row.swrStale}/` +
`${row.maxBlockingSaved.toFixed(3)}/${row.maxSwrErrors}:` +
`${row.validatorOriginCalls}`
),
byMaxAge: groupByAxis(audit.cases, "maxAge").map(aggregateShape),
bySwr: groupByAxis(audit.cases, "swr").map(aggregateShape),
byLatency: groupByAxis(audit.cases, "latency").map(aggregateShape)
};
}
const audit = lab.auditHttpCacheLab();
const shape = auditShape(audit);
const failed = audit.cases.filter(
(row) => !row.passed || row.passedChecks !== row.totalChecks
);
const checkNameShape = lab.evaluate({}).audit.checks.map((row) => row.name);
const shapeErrors = [
sameJson(shape.totals, EXPECTED_TOTALS) ? null : "totals",
sameList(shape.cases, EXPECTED_CASE_KEYS) ? null : "cases",
sameList(checkNameShape, EXPECTED_CHECK_NAMES) ? null : "checkNames",
shape.checksByCase.every((checks) => sameJson(checks, EXPECTED_CHECK_SHAPE))
? null
: "checksByCase",
sameList(shape.byScenario, EXPECTED_BY_SCENARIO) ? null : "byScenario",
sameList(shape.byMaxAge, EXPECTED_BY_MAX_AGE) ? null : "byMaxAge",
sameList(shape.bySwr, EXPECTED_BY_SWR) ? null : "bySwr",
sameList(shape.byLatency, EXPECTED_BY_LATENCY) ? null : "byLatency"
].filter(Boolean);
console.table(audit.byScenario.map((row) => ({
scenario: row.scenario,
cases: row.cases,
passed: row.passed,
passedChecks: row.passedChecks,
totalChecks: row.totalChecks,
validator304: row.validator304,
swrStale: row.swrStale,
maxBlockingSaved: row.maxBlockingSaved.toFixed(2),
maxSwrErrors: row.maxSwrErrors
})));
const summary =
`${audit.passed}/${audit.total} parameter cases and ${audit.passedChecks}/${audit.totalChecks} checks passed`;
if (
shapeErrors.length ||
failed.length ||
!audit.ok ||
audit.passed !== audit.total ||
audit.passedChecks !== audit.totalChecks
) {
throw new Error(JSON.stringify({
summary,
shapeErrors,
shape,
failed,
failures: audit.failures,
passed: audit.passed,
total: audit.total,
passedChecks: audit.passedChecks,
totalChecks: audit.totalChecks
}, null, 2));
}
console.log(summary);
NODE
The full source is in
assets/js/http-cache-validator-lab.js.
Stale-While-Revalidate Moves The Wait
RFC 5861 defines stale-while-revalidate: after a response becomes stale, a
cache may continue serving it for the indicated number of seconds while it
attempts asynchronous validation.3
That is not a magic freshness upgrade. It is a latency trade:
ordinary validation:
stale request waits for origin
stale-while-revalidate:
stale request gets old bytes now
cache asks origin in the background
later requests may see the refreshed body
The first user after expiration no longer pays the validation round trip. But that user may see an older representation. The server chooses how much staleness it can tolerate.
RFC 9111 is strict about this boundary. A cache must not generate a stale
response when directives such as no-cache or must-revalidate prohibit it,
and must not generate stale responses unless disconnected or explicitly
permitted by client, origin, extension directives, or an out-of-band
contract.4
So stale-while-revalidate is not “serve stale whenever convenient.” It is a specific permission with a clock attached.
The Validator Has To Be Stable Across The Fleet
ETags fail quietly when the fleet cannot agree on them.
Bad patterns include:
ETag includes local inode or mtime from one machine
ETag includes a build path or per-host compression artifact
ETag changes on every render even when the body is equivalent
ETag stays fixed while the body changes
The first two destroy 304 rates. The last one is worse: it can certify the wrong body. For static assets, content hashes in filenames often dodge the problem:
/app.6f8b2a.js
Cache-Control: max-age=31536000, immutable
The URL itself becomes the version. For mutable URLs such as /article,
/profile, or /api/config, the validator is the witness. It should be derived
from the representation semantics the client actually receives, including
content negotiation when Vary is involved.
What To Measure
For a real service, I would want cache metrics that keep these concepts separate:
fresh hits
stale hits served by explicit policy
blocking validations
background validations
304 responses
200 replacements after validation
validator mismatch rate across origin nodes
bytes saved at origin
client wait saved
stale view budget consumed
One aggregate “hit ratio” hides too much. A fresh hit, a stale hit, and a 304 all mean “we did not send a full body,” but they have different correctness and latency stories.
The cache entry needs a witness because time alone is a blunt instrument. Freshness decides when not to ask. Validation decides how to ask cheaply when the permission expires.
-
Roy Fielding, Mark Nottingham, and Julian Reschke, RFC 9111, “HTTP Caching”, June 2022. The introduction defines fresh reuse, validation, and limited stale reuse. ↩
-
Roy Fielding, Mark Nottingham, and Julian Reschke, RFC 9110, “HTTP Semantics”, Section 8.8.3, June 2022. The ETag section notes that entity tags can be more reliable than modification dates when timestamp resolution or consistency is inadequate. ↩
-
Mark Nottingham, RFC 5861, “HTTP Cache-Control Extensions for Stale Content”, May 2010. Section 3 defines
stale-while-revalidateand gives themax-age=600, stale-while-revalidate=30example. ↩ -
RFC 9111, Section 4.2.4, “Serving Stale Responses”, states when stale responses are prohibited or explicitly permitted. ↩