HTTP If-None-Match and Weak ETags — Unexpected 200
Fix revalidation always returning 200 by generating stable ETags, using weak comparison correctly, and keeping Vary and Cache-Control consistent.
Symptoms
- Conditional GETs with If-None-Match always return 200, not 304
- The ETag changes on every request for identical content
- A CDN caches a stale body while the origin revalidates to 200
Possible causes
- The ETag is derived from a timestamp or random value, so it is never stable
- Strong versus weak comparison is mishandled when a W/ prefix is involved
- Vary or Cache-Control is inconsistent, causing the cache and origin to disagree
Diagnosis steps
Inspect ETag stability across requests
curl -sI https://api.example.com/items | grep -i etag; sleep 1; curl -sI https://api.example.com/items | grep -i etagInterpretation
Different ETags for identical content mean the generator is non-deterministic.
Test the conditional request
curl -i -H 'If-None-Match: "abc123"' https://api.example.com/itemsInterpretation
A 304 confirms matching works; a 200 means the server did not compare correctly.
Root cause
If-None-Match uses weak comparison for ETags, and a 304 is returned only when the current representation matches. A non-deterministic ETag or a mismatched comparison mode prevents a match, so the server always sends 200.
Fix
Generate ETags from a stable content hash, honor weak comparison for If-None-Match, set Cache-Control consistently with the validator, and include Vary when responses depend on request headers.
Risk note
Weak ETags permit semantically equivalent but byte-different representations; do not use them where byte-exact range requests matter.
Verify the fix
Send the returned ETag back in If-None-Match and confirm 304; change the content and confirm the ETag changes and a 200 is returned.
Symptoms
This guide profiles the failure behind “HTTP If-None-Match and Weak ETags — Unexpected 200”. It walks through the observable symptoms, a reproducible diagnosis path, the underlying root cause, the concrete fix, and how to verify the change and prevent a regression.
Observed symptoms include:
- Conditional GETs with If-None-Match always return 200, not 304
- The ETag changes on every request for identical content
- A CDN caches a stale body while the origin revalidates to 200
Reproduce and Diagnose
Reproduce the failure in isolation before changing any code. Work through the checks below in order; each one narrows the search space.
Step 1: Inspect ETag stability across requests
curl -sI https://api.example.com/items | grep -i etag; sleep 1; curl -sI https://api.example.com/items | grep -i etag
Different ETags for identical content mean the generator is non-deterministic.
Step 2: Test the conditional request
curl -i -H 'If-None-Match: "abc123"' https://api.example.com/items
A 304 confirms matching works; a 200 means the server did not compare correctly.
Likely contributors, in order of frequency:
- The ETag is derived from a timestamp or random value, so it is never stable
- Strong versus weak comparison is mishandled when a W/ prefix is involved
- Vary or Cache-Control is inconsistent, causing the cache and origin to disagree
Root Cause
If-None-Match uses weak comparison for ETags, and a 304 is returned only when the current representation matches. A non-deterministic ETag or a mismatched comparison mode prevents a match, so the server always sends 200.
The Fix
Generate ETags from a stable content hash, honor weak comparison for If-None-Match, set Cache-Control consistently with the validator, and include Vary when responses depend on request headers.
Watch out: Weak ETags permit semantically equivalent but byte-different representations; do not use them where byte-exact range requests matter.
import { createHash } from 'node:crypto';
const body = JSON.stringify(items);
const etag = 'W/"' + createHash('sha256').update(body).digest('hex').slice(0, 16) + '"';
if (req.headers['if-none-match'] === etag) {
return res.status(304).end(); // representation unchanged
}
res.setHeader('ETag', etag);
res.setHeader('Cache-Control', 'public, max-age=0, must-revalidate');
res.type('application/json').send(body);
Verify and Prevent
Send the returned ETag back in If-None-Match and confirm 304; change the content and confirm the ETag changes and a 200 is returned.
Once verified, add a regression test that reproduces the original symptom so the failure cannot silently return.
This class of failure is usually environmental rather than pure logic, so capture the exact command, the environment variables, and the working directory in your bug report. Reproduce it in the smallest possible script, then widen the scope only once the minimal case is understood. A short written checklist for this failure mode saves hours the next time it appears.