A client checks for a product, creates it, reads it back, and gets 404 for the next two minutes.
The first 404 carried no Cache-Control, so the cache in front of the API gave it a lifetime of its
own. On the payments route, no-cache was meant to keep card data out of every cache, but it lets a shared
cache store the response and only revalidate it.
What you get
You will end up with an endpoint-class table, a check that compares each route’s header with its row, and a test that counts origin requests through a shared cache. This is for you if a CDN or Varnish sits in front of your API.
Short answer
Write a table that gives each class of endpoint one Cache-Control value before any handler sets a
header. Shared data that may be N seconds old gets public, max-age=N. Shared data that must never
be stale gets public, no-cache and an ETag. Per-user data gets private, no-cache. Regulated
data and a 404 for a record that can be created get no-store, and a 5xx gets no freshness at all.
You will need
Node 22 or later, and an endpoint that already sends an ETag. Setting ETag and Cache-Control on
API responses is the step before this one in the series. Verified 2026-10-15 against Node
22.22.0 and http-cache-semantics 4.3.0. Every rule below comes from
RFC 9111, the HTTP caching standard, rather than
from what a directive’s name suggests.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| max-age fresh window | Shared data that may be a bounded number of seconds old, such as exchange rates | A caller can read a value up to N seconds after it changed, and you cannot recall it | A stale read is a bug, or the data belongs to one caller |
no-cache plus ETag revalidation | Shared data that has to be current on every read | One origin round trip per read, though a 304 carries no body | The origin cannot take a request per read, or some staleness is fine |
| no-store | Regulated data, and errors that must not outlive the condition | Every read is a full origin response, so no cache absorbs a spike | The data is the same for every caller and may be cached |
| RFC 9111 heuristic freshness | Never by choice: it is what a cache does when you send nothing | Each cache picks its own lifetime, so two caches in one path disagree | Any response you can name a policy for |
no-cache with an ETag buys zero staleness for one round trip per read, and the round trip is
cheap because the answer is usually a 304 with no body. max-age removes the round trips and
pays in staleness, bounded by the number you chose. no-store pays in origin load on every read,
and it is the only one of the three that keeps the response out of the cache’s storage. Heuristic
freshness is not a policy at all, because the cache picks the number.
Write the table before the handlers
A directive belongs to a class of endpoint, not to a handler. Write the classes down first, one value each, and make every route name its class. The first five rows of the sample’s table:
export const CLASSES = {
versioned: {
path: '/schemas/v3/invoice.json',
status: 200,
cacheControl: 'public, max-age=31536000, immutable',
why: 'the URL changes whenever the bytes do',
},
shared: {
path: '/rates',
status: 200,
cacheControl: 'public, max-age=60',
why: 'the same for every caller, and a minute of staleness is acceptable',
},
revalidate: {
path: '/products/42',
status: 200,
cacheControl: 'public, no-cache',
why: 'the same for every caller, and a stale price is a bug',
},
perUser: {
path: '/me',
status: 200,
cacheControl: 'private, no-cache',
why: 'one caller only, so the browser may keep it and a shared cache must not',
},
regulated: {
path: '/me/payment-methods',
status: 200,
cacheControl: 'no-store',
why: 'no cache anywhere may keep a copy',
},
public appears on every shared row because API requests carry Authorization.
Section 3.5 forbids a shared cache to
reuse the answer to such a request unless the response says public, s-maxage, or
must-revalidate. immutable tells a cache not to revalidate inside max-age, which is safe only
because the version is in the path.1 private, no-cache lets the caller’s browser keep a copy
and check it with the ETag, while a shared cache keeps nothing.
The difference between the two words people confuse is in the standard’s own text. no-cache
means the response must not be used without validation, so a cache may store it. no-store means
a cache must not store it at all.
Count origin requests through a shared cache
No Varnish or CDN runs inside the sample, so it models one. Storage is a Map, and the freshness
rules come from http-cache-semantics
in its shared-cache mode. Its README lists make-fetch-happen, and got through cacheable-request,
among its users. Time comes from a clock
the test moves.
export function createSharedCache({ origin, clock, defaultTtl = null, name = 'ModelCache' }) {
class Policy extends CachePolicy {
now() { return clock.now() }
}
Each policy runs on the same endpoint: ten reads, 10 seconds apart, with the price changed at 25 seconds. The predicted origin requests were written into the demo before it ran.
node demo.mjs
GET /products/42 through one shared cache: ten reads 10 s apart, price changes at t=25s
policy origin bodies 304s predicted reads of the old price
public, no-cache 10 2 8 10 0
public, max-age=60 2 2 0 2 3
no-store 10 10 0 10 0
no-cache reached the origin on all ten reads, and eight of them came back as 304 responses
with no body. max-age=60 reached it twice and served the previous price three times, which is the staleness
you bought. no-store sent ten full bodies. All three matched the prediction, which is the point
of predicting: a count that surprises you is a directive that does not mean what you thought.
Keep a 404 from outliving the record
A 404 is heuristically cacheable. RFC 9110
lists 200, 203, 204, 206, 300, 301, 308, 404, 405, 410, 414, and 501 as statuses a cache may
reuse with no explicit directives.2 How long it keeps them is the cache’s choice.
Varnish applies default_ttl, two minutes, to a
404 with no Cache-Control and no Expires.3 The demo’s defaultTtl option models that rule.
node demo.mjs
GET /products/77 before the record exists, create it, read again 30 s later
404 with no directives, cache with a 120 s default
first 404, second 404 ModelCache; hit; ttl=90
404 with no directives, http-cache-semantics alone
first 404, second 200 ModelCache; fwd=stale; fwd-status=200; stored
404 with no-store, cache with a 120 s default
first 404, second 200 ModelCache; fwd=uri-miss; stored
The first case is the support ticket: the record exists, but the cache still answers 404, with 90
seconds of freshness left, in the Cache-Status syntax of
RFC 9211. The library alone recovers because its heuristic needs a Last-Modified header and the
404 has none. Your users do not choose which cache they get. no-store removes the choice.
If 404 traffic is heavy, max-age=5 bounds the damage at five seconds instead.
Leave freshness off a 5xx
A 500, 502, 503, or 504 is not heuristically cacheable, so a compliant cache stores it only when
the response carries explicit freshness or public. RFC 9110
says any final status can be cached once it has explicit freshness. A middleware that stamps the
route’s max-age=300 on every response does that to the error too.
CloudFront
caches a 503 for the longer of its error caching minimum TTL, 10 seconds by default, and the
origin’s max-age. The 503 can then be served for up to five minutes after the outage ends. Send
Retry-After on the 5xx and no freshness directives.
Check it worked
Run the header check. It requests one URL per class and compares directive sets, so
max-age=60, public matches public, max-age=60, and it exits non-zero on a mismatch.
node check-headers.mjs
versioned /schemas/v3/invoice.json 200 public, max-age=31536000, immutable ok
shared /rates 200 public, max-age=60 ok
revalidate /products/42 200 public, no-cache ok
perUser /me 200 private, no-cache ok
regulated /me/payment-methods 200 no-store ok
notFound /products/77 404 no-store ok
serverError /reports/daily 503 (none) ok
Point it at a deployed API with BASE_URL, which runs the same comparison against what your
gateway or framework sends rather than what the handler meant to send.
BASE_URL=https://api.example.com node check-headers.mjs
The test runs the header table, the three origin counts, both 404 cases, and a check that an
authenticated request is stored only when the response says public.
node --test cache.test.mjs
1..9
# tests 9
# suites 0
# pass 9
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 337.398354
When it goes wrong
The cache’s hit count stays at zero. Your callers send Authorization and the response lacks
public, so a shared cache stores nothing. Cloudflare’s cache status
documentation lists the same
rule as a reason for BYPASS. Add public to the rows whose data is the same for every caller.
Every read reaches the origin under max-age=60, must-revalidate. A cache built on
http-cache-semantics 4.3.0 validates every read with the origin when it sees must-revalidate, not
only once the response is stale.
node demo.mjs
public, max-age=60, must-revalidate: 10 origin requests, RFC 9111 predicts 2
Section 5.2.2.2 applies the directive to stale responses only, so the library is stricter than the standard here, and the model inherits that. The standard reserves the directive for responses where a failed validation could cause incorrect operation, so drop it from the rest.
When not to do this
Do not mark a URL immutable unless the URL changes when the bytes do. A year of freshness on
/rates pins one value in every browser that fetched it, because a browser holding a fresh copy
does not ask the origin again until the year is up.
Do not treat no-store as protection for regulated data. RFC 9111 says itself that the directive
is not a reliable or sufficient mechanism for privacy, since a compromised cache can ignore it.
Keep the data out of URLs and logs, and encrypt it in transit.
Do not reach for s-maxage to give the edge a different lifetime unless you have read what else
it does. It carries the semantics of proxy-revalidate for shared caches, which forbids them to
serve the response stale. A stale-while-revalidate window added later is then ignored by a cache
that follows the standard, as
Cloudflare documents that it
does.
Do not build a seven-row table for an internal API that no shared cache fronts and whose clients
never cache. One default of no-store is cheaper to review, and the table earns its keep only
where a cache can act on it.
Related how-tos
Last verified
Verified 2026-10-15 against Node 22.22.0 and http-cache-semantics 4.3.0. Every output block is what the command preceding it printed. The shared cache is a model built on http-cache-semantics, not Varnish or a CDN: the Varnish, Cloudflare, and CloudFront behavior is quoted from their documentation, not measured.
Footnotes
-
RFC 8246, which defines
immutable, opens with a newspaper’s front-page photo and the reload button. A browser refreshing a page could send hundreds of conditional requests for its parts, each answered with a 304, because nothing told it that a versioned URL never changes. The fix is a single word in a header, and it needed a standards-track RFC because a browser has no other way to learn that a URL is versioned. ↩︎ Back to text -
Heuristic freshness once came with a rule about query strings. RFC 2616 forbade a cache to compute heuristic freshness for a URI containing a question mark. RFC 9111 retires that rule with one sentence of history: in practice, this has not been widely implemented. Origin servers that want no caching are encouraged, in the same paragraph, to send explicit directives instead. ↩︎ Back to text
-
A 404 that says nothing is kept for two minutes by Varnish, for three minutes by Cloudflare once a rule makes the URL eligible, and for ten seconds by CloudFront. Each figure sits in its vendor’s documentation with no derivation beside it, which leaves the origin’s own header as the only number anyone chose for your API. ↩︎ Back to text