How-to › Make calls that survive failure

How to choose Cache-Control values for private and shared caches#

Give every class of endpoint one Cache-Control value from RFC 9111, check that each route sends it, and count the origin requests a shared cache makes.

Audience
API producer
Level
intermediate
Topic
Cache API responses
Verified

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
max-age fresh windowShared data that may be a bounded number of seconds old, such as exchange ratesA caller can read a value up to N seconds after it changed, and you cannot recall itA stale read is a bug, or the data belongs to one caller
no-cache plus ETag revalidationShared data that has to be current on every readOne origin round trip per read, though a 304 carries no bodyThe origin cannot take a request per read, or some staleness is fine
no-storeRegulated data, and errors that must not outlive the conditionEvery read is a full origin response, so no cache absorbs a spikeThe data is the same for every caller and may be cached
RFC 9111 heuristic freshnessNever by choice: it is what a cache does when you send nothingEach cache picks its own lifetime, so two caches in one path disagreeAny 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.

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

  1. 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

  2. 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

  3. 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

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.