How to Debug 304 Not Modified Responses
When conditional requests stop working, they fail in one of two directions, and the fix is different for each. Either the origin never returns 304 Not Modified — every request downloads the full body again, bandwidth and origin load stay high, and caches never get to skip work — or the origin returns 304 when it shouldn’t, and clients end up stuck displaying stale content because the server insists nothing has changed. This page is a linear workflow for isolating which of those two problems you have, and which specific stage in the validator pipeline is responsible.
Prerequisite Concepts
- Conditional Requests and 304 Responses — the precedence rules between
If-None-Match,If-Modified-Since,If-Match, andIf-Unmodified-Since, and the exact header set RFC 9110 requires on a304. - ETag Generation and Validation Strategies — how a validator is computed from a representation, which matters because most “304 never fires” bugs trace back to validator generation, not the conditional-request logic itself.
- Serving Stale Content with stale-while-revalidate — relevant if the symptom is a cache serving old content well past its freshness window rather than a wrong
304decision at origin.
Step-by-Step Resolution
Step 1 — Confirm the origin actually emits a validator
If there’s no ETag and no Last-Modified on the plain response, no conditional request can ever succeed — there’s nothing for a client to echo back.
curl -sI https://example.com/api/products/9472 \
| grep -iE 'etag|last-modified|cache-control'
Expected output:
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
Cache-Control: private, max-age=0, must-revalidate
If neither header appears, stop here — this is the root cause. Add validator emission at the origin (a content hash for ETag, or a reliable modification timestamp for Last-Modified) before testing anything downstream.
Step 2 — Confirm a matching validator produces 304, not 200
curl -s -o /dev/null -w 'status=%{http_code}\n' \
-H 'If-None-Match: "33a64df551425fcc55e4d42a148795d9f25f89d4"' \
https://example.com/api/products/9472
Expected: status=304.
If you get status=200 here, the origin received the header but its comparison logic isn’t matching. Common causes: the framework strips quotes from the ETag before comparing (33a64df... vs "33a64df..." never match as strings), or the comparison runs before the current validator is computed, comparing against a placeholder value.
Step 3 — Confirm the header actually reaches the origin unmodified
A 200 in Step 2 can also mean the header never arrived. Add a temporary logging line at the origin, or use a debug endpoint that echoes received headers:
curl -s -H 'If-None-Match: "test-probe-value"' \
https://example.com/debug/echo-headers | grep -i if-none-match
If this comes back empty, something between the client and the application — a load balancer, a WAF rule, or an API gateway — is stripping If-None-Match before your application code ever sees it. This is common with security appliances configured to strip “unrecognized” or “non-essential” request headers by default.
Step 4 — Check ETag stability across repeated requests and across servers
Request the same unmodified resource multiple times, ideally against different backend instances if you’re behind a load balancer:
for i in 1 2 3 4 5; do
curl -sI https://example.com/api/products/9472 | grep -i etag
done
Expected: the identical ETag value on every line. If the value changes between requests for content that has not changed, the validator is derived from something instance-specific — a per-process object hash, an inode number, or a timestamp that resets per server — rather than the actual representation. Every conditional request will then spuriously miss, because the client’s stored validator never matches whichever instance happens to answer next.
Step 5 — Check for a Vary-driven false match or false miss
If the resource has encoding or language variants, confirm the validator differs correctly per variant:
curl -sI -H 'Accept-Encoding: gzip' https://example.com/api/products/9472 | grep -i etag
curl -sI -H 'Accept-Encoding: identity' https://example.com/api/products/9472 | grep -i etag
Expected: two different ETag values, one per encoding. If they’re identical, a client that negotiated one encoding can receive a 304 referencing a stored variant it never actually fetched — a false match. See Mapping Vary Headers to Edge Routing for how Vary fragments cache storage; the same fragmentation must be reflected in validator generation.
Step 6 — Confirm precedence is implemented correctly
If both If-None-Match and If-Modified-Since are sent, If-Modified-Since must be ignored entirely:
curl -sI \
-H 'If-None-Match: "33a64df551425fcc55e4d42a148795d9f25f89d4"' \
-H 'If-Modified-Since: Thu, 01 Jan 1998 00:00:00 GMT' \
https://example.com/api/products/9472
Expected: 304, despite the ancient If-Modified-Since date. A 200 here indicates the application is checking If-Modified-Since first (or in addition), which violates the RFC 9110 precedence rule and can cause both false matches and false misses depending on which header happens to be stale.
Step 7 — Trace the path through any intermediate cache layer
If Steps 1–6 pass directly against the origin but the symptom persists for real users, the problem is between the origin and the client. Test at each hop:
# Direct to origin (bypass CDN via a debug hostname or IP override)
curl -sI -H 'If-None-Match: "33a64df..."' https://origin.internal/api/products/9472
# Through the CDN
curl -sI -H 'If-None-Match: "33a64df..."' https://example.com/api/products/9472
If the origin correctly returns 304 but the CDN response is 200 with a full body, the CDN is either not forwarding If-None-Match upstream, or it answered from its own edge cache using a different (stale or malformed) stored validator instead of relaying the origin’s decision.
Expected Output / Verification
A healthy conditional-request pipeline shows, in order:
- Step 1:
ETagorLast-Modifiedpresent on the unconditional response. - Step 2:
304for a matching validator, empty body, and the RFC 9110 §15.4.5 header set (ETag,Cache-Control,Date, andExpires/Varywhere applicable) — noContent-Lengthdescribing a body that doesn’t exist. - Step 4: an identical
ETagacross repeated and cross-instance requests for unchanged content. - Step 5: distinct
ETagvalues perVarydimension. - Step 6:
304decided purely byIf-None-Matchwhen both conditional headers are present. - Step 7: identical status code and validator behavior whether tested directly against the origin or through the full CDN path.
Any deviation from this sequence pinpoints exactly which stage — emission, transport, stability, variant scoping, precedence, or relay — is broken.
Edge Cases
- Browser vs. CDN divergence — a browser may show
200 (from disk cache)locally while the CDN, if queried directly, correctly returns304for the same validator. This isn’t a bug: the browser’s localmax-agewindow hadn’t expired yet, so it never issued a conditional request at all. Force revalidation with a hard reload before concluding the server side is broken. - HTTP/1.1 vs. HTTP/2 — conditional request semantics are identical across protocol versions; a
304behaves the same over either. If a difference appears, suspect a proxy or gateway configuration that treats the two protocols through separate code paths, not the conditional-request logic itself. - Missing validators after a framework upgrade — some web frameworks disable automatic
ETaggeneration for streamed or dynamically-sized responses. A resource that used to revalidate correctly can silently lose that ability after a dependency upgrade; re-run Step 1 after any framework or middleware change. - Compressed responses with a shared ETag — if gzip and brotli variants of the same resource are assigned one
ETagcomputed pre-compression, this is actually correct (the validator represents the underlying representation, not its encoding) — but only if your cache layer’s key includes the encoding dimension viaVary. Confirm both pieces are consistent rather than assuming a sharedETagis automatically a bug. - Clock skew masking as a validator bug — if you’re relying on
If-Modified-Sincerather thanETag, an origin server with a clock several seconds ahead of the CDN’s clock can produce inconsistent304/200decisions that look like a broken comparison but are actually a synchronization problem. Confirm NTP sync before debugging the comparison logic itself.
Frequently Asked Questions
Why does my server always return 200 even when I send If-None-Match?
Most often the origin never runs a comparison against a current validator — either it doesn’t compute one per request, or a caching middleware layer is bypassed for that route. Confirm with Step 2 above; if the header arrives (Step 3) but the response is still 200, the comparison logic itself is the bug, commonly a quoting mismatch between the stored and incoming ETag values.
Why do I get a 304 for content that has actually changed?
The validator is being computed from something that doesn’t track the actual response body — a cache key, a template hash that excludes the dynamic portion of the page, or a Last-Modified timestamp that isn’t updated on every content change. Recompute the validator from the literal bytes of the response, or switch from Last-Modified to a content-hash ETag.
Can a CDN cause 304 problems even when my origin is correct?
Yes. A CDN sitting in front of a correctly-behaving origin can still strip If-None-Match before it reaches the origin, answer from its own edge copy using a stale validator, or fail to propagate Vary into its comparison. Step 7 above isolates this by testing the origin directly and through the CDN separately.
Is a 304 response ever expected to have a body I should check?
No. Per RFC 9110 §15.4.5, a 304 response is always terminated at the first empty line after the headers — there is no body to inspect. If you’re seeing bytes after that point in your debugging tool, an intermediary is appending content it shouldn’t, which is itself a protocol violation worth reporting to that vendor.
Related
- Strong vs Weak ETags Explained — the comparison rules that decide whether a weak validator is even eligible to produce a match for a given conditional header.
- Handling Clock Skew in Last-Modified Validation — a deeper walkthrough of the clock-synchronization edge case referenced above.
- How CDN Cache Keys Are Generated — background on why a validator must be scoped identically to the cache key when variants are involved.