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

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: ETag or Last-Modified present on the unconditional response.
  • Step 2: 304 for a matching validator, empty body, and the RFC 9110 §15.4.5 header set (ETag, Cache-Control, Date, and Expires/Vary where applicable) — no Content-Length describing a body that doesn’t exist.
  • Step 4: an identical ETag across repeated and cross-instance requests for unchanged content.
  • Step 5: distinct ETag values per Vary dimension.
  • Step 6: 304 decided purely by If-None-Match when 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 returns 304 for the same validator. This isn’t a bug: the browser’s local max-age window 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 304 behaves 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 ETag generation 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 ETag computed 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 via Vary. Confirm both pieces are consistent rather than assuming a shared ETag is automatically a bug.
  • Clock skew masking as a validator bug — if you’re relying on If-Modified-Since rather than ETag, an origin server with a clock several seconds ahead of the CDN’s clock can produce inconsistent 304/200 decisions 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.


Back to Conditional Requests and 304 Responses