← Debugging Guides

阅读中文版本

HTTP — ETag Revalidation Serves Stale or Never Caches

Debug ETag and If-None-Match behavior: 304 responses that carry changed content, caches that never store, and weak versus strong validator mismatches.

intermediatedebugginghttpcachingetag

Symptoms

  • A 304 Not Modified response arrives although the content changed, or a 200 arrives although nothing changed
  • CDN or browser caches never store the response at all, revalidating every time
  • Caches serve one variant to all users because the ETag ignores the credential or language dimension

Possible causes

  • The ETag is computed from something invariant (URL, timestamp at boot) rather than the body, so changed content keeps the old validator
  • Weak validators (W/) are compared byte-exact by an intermediate that upgrades them incorrectly
  • Vary is missing or wrong, so the stored ETag matches requests for which the body would differ
  • Set-Cookie or Authorization presence makes caches treat the response as uncacheable, and no-store headers override the validator entirely

Diagnosis steps

Test the validator directly

ETAG=$(curl -sI https://app.example.com/data | grep -i etag); curl -s -o /dev/null -w "%{http_code}\n" -H "If-None-Match: ${ETAG#*: }" https://app.example.com/data

Interpretation

A 200 after unchanged content means the validator is unstable; a 304 after a real content change means the validator ignores the body.

Check what the ETag is derived from

grep -Rn "etag\|ETag" src/ middleware/ | head

Interpretation

Validators built from mtime or constant strings never change with content; hash-of-body (or content hash of dependencies) is the correct derivation.

Inspect cacheability blockers

curl -sI https://app.example.com/data | grep -iE "cache-control|vary|set-cookie|etag"

Interpretation

Set-Cookie on the response or missing Vary for the changing dimension defeats correct validators: caches store nothing or store the wrong variant.

Root cause

A conditional 304 is only as correct as the validator. ETags derived from invariants never change when content does, serving stale 304s; unstable validators never match, defeating caching; and missing Vary lets one stored variant satisfy requests whose correct responses would differ.

Fix

Derive ETags from the response body or its content dependencies, set Vary for every request dimension that changes the body, keep validators stable across identical bodies, and remove cacheability blockers (Set-Cookie on cacheable routes) from static or shared endpoints.

Risk note

Weak validators (W/) enable semantic equality but break byte-range and strict intermediaries. Use strong validators for content that changes byte-wise.

Verify the fix

Unchanged content returns 304 with the same validator; changed content returns 200 with a new validator; both language variants cache separately under a correct Vary.

Symptom walkthrough

The two failure directions share one instrument. Direction one — stale 304s:

$ curl -sI https://app.example.com/data
ETag: "v1-boot-8a01"        <- computed at process start

# content changes, deploy happens:
$ curl -sI https://app.example.com/data
ETag: "v1-boot-8a01"        <- unchanged: the validator ignores the body

Direction two — never cached: every request returns 200 with a fresh validator because the ETag embeds request time or a random salt, so If-None-Match never matches. Both are derivation bugs, exposed by the two-curl test: unchanged content must 304, changed content must 200-with-new-ETag.

Fix in practice

Derive the validator from what the response actually is:

const hash = crypto.createHash('sha1')
  .update(JSON.stringify(data))
  .update(acceptLanguage)     // every Vary dimension participates
  .digest('base64');

res.set({
  'ETag': `"${hash}"`,        // strong validator
  'Vary': 'Accept-Language',  // the changing dimension
  'Cache-Control': 'public, max-age=0, must-revalidate',
});

For static assets, let the build do it: content-hashed filenames make the validator question trivial — /app.a91f.js never changes content, so it caches forever with immutable.

And clear the blockers: middleware injecting Set-Cookie onto shared responses converts them to uncacheable, and an accidental no-store overrides the best validator in the world.