Caching is not a single switch. A response has a freshness decision made by a cache and a validation conversation with the origin. Mixing those two ideas produces either stale content or needless requests.

A storage and reuse matrix is followed by stable and fingerprinted URL policies. A conditional GET sends the real ETag in If-None-Match: a weak match can return 304, while a changed validator normally returns a new 200. Weak equivalence need not mean equal bytes. Age, Vary, freshness-limited immutable, and previously cached lifetimes constrain reuse.
Storage, freshness, and validation answer separate questions. Match the policy to how the URL changes, preserve validators exactly, and check shared-cache variants.

Pick the change model first

For an HTML document that may change after publication, Cache-Control: no-cache is usually a more accurate starting point than no-store. no-cache permits storage but requires a cache to validate before reuse; no-store asks caches not to store the response at all. The distinction matters when you want conditional requests and a cheap 304 Not Modified response.

For a fingerprinted asset such as app.4f2c.js, the URL changes when the bytes change. A long freshness lifetime can then be reasonable:

Cache-Control: public, max-age=31536000, immutable

That policy is unsafe for a stable URL whose contents can be replaced in place. If the URL is stable, prefer a shorter lifetime and a validator such as an entity tag (ETag) or a meaningful Last-Modified value.

Choose who may store the response as well as how long it stays fresh. Use the unqualified private directive when shared caches must not store user-specific content; a browser’s private cache can still store it. no-cache alone does not provide that restriction. The public example above is for assets safe to share across users, not a default for personalized responses.

Inspect both paths

Make one ordinary request and one conditional request. The exact server flags vary, but the evidence you want is stable:

curl -sS -D - -o /dev/null https://www.example.test/
curl -sS -D - -o /dev/null \
  -H 'If-None-Match: "copy-a-real-etag-from-the-first-response"' \
  https://www.example.test/

Do not paste a made-up validator into a runbook. Copy the value from the first response or use a test fixture. For this conditional GET, a matching If-None-Match can yield 304 using weak comparison: it permits semantic equivalence, not necessarily byte identity. A changed validator normally produces a new 200 response. Keep the ETag’s quotes and any W/ prefix intact.

Keep intermediaries visible

Age, Vary, and the cache-control directives explain why two clients can receive different results. If a response varies by Accept-Encoding, language, or another request field, that dimension belongs in Vary; otherwise a shared cache can reuse the wrong representation.

The useful release check is not “the scanner found a cache header”. It is “the header matches the replacement policy, and a conditional request behaves as documented”.

immutable applies during the advertised freshness lifetime; it is not a permanent ban on revalidation. Keep versioned URLs immutable and older assets reachable while old HTML can still reference them. Changing today’s cache header does not retroactively shorten a previously cached response’s lifetime, so a cache-policy mistake may require a new URL or an explicitly supported cache purge.

Sources