Conditional Requests and 304 Responses
TL;DR: A conditional request carries a validator the client already holds — If-None-Match or If-Modified-Since for reads, If-Match or If-Unmodified-Since for writes — and asks the origin to act only if that validator no longer matches. When it still matches, the origin skips the expensive work and returns 304 Not Modified: no body, just enough headers for the cache to keep serving the copy it already has.
Quick-reference header block for a revalidated static asset:
GET /assets/dashboard.a1c9e2.css HTTP/1.1
Host: example.com
If-None-Match: "a1c9e2-14980"
HTTP/1.1 304 Not Modified
ETag: "a1c9e2-14980"
Cache-Control: public, max-age=31536000, immutable
Date: Mon, 06 Jul 2026 10:00:00 GMT
Mechanism and RFC 9111 Alignment
A conditional request is an ordinary request — GET, HEAD, PUT, DELETE, whatever the method calls for — with one or more validator-bearing precondition headers attached. RFC 9110 defines four of them:
If-None-Match— “only proceed if none of these validators match the current representation.” Used almost exclusively withGET/HEADto trigger revalidation. Carries one or moreETagvalues, or*to mean “only if the resource doesn’t exist at all.”If-Modified-Since— “only proceed if the representation has changed since this date.” A lower-resolution fallback for origins that emitLast-Modifiedinstead of an ETag.If-Match— “only proceed if one of these validators still matches.” Used on unsafe methods (PUT,PATCH,DELETE) to prevent a client from overwriting a version of the resource it never actually read — the classic lost-update guard.If-Unmodified-Since— the date-based equivalent ofIf-Match, for origins without ETag support.
For the read path, the mechanism is: the client stores a representation along with whatever validator the origin sent, and on the next request for the same URL it echoes that validator back. The origin compares the echoed value against the validator the current representation would produce. A match means nothing has changed from the client’s point of view, so re-sending the body is wasted bandwidth — the origin answers with 304 Not Modified instead of 200 OK. A cache that receives a 304 treats it as permission to reuse its stored body under a refreshed freshness lifetime, which is the core mechanism RFC 9111 calls revalidation: caches don’t re-download content they can prove is still correct, they only re-confirm it. This is the operational counterpart to expiration-based freshness versus validation — expiration decides when to check, conditional requests decide how to check cheaply.
For the write path, the mechanism inverts: If-Match and If-Unmodified-Since exist to make the operation fail loudly (412 Precondition Failed) rather than silently clobber a concurrent edit. There is no body-saving benefit here — the point is correctness, not bandwidth.
RFC 9110 §15.4.5 — what a 304 response must (and must not) contain
A 304 Not Modified response has two hard requirements:
- No message body. Per RFC 9110 §15.4.5, a
304response is always terminated by the first empty line after the header fields. There is noContent-Length-bounded or chunked body to read — the response ends where the headers end. - A defined header set. The origin must resend any of
Content-Location,Date,ETag,Expires, andVarythat would have been present in the200 OKthis request would otherwise have produced. In practice,Cache-Controlis sent alongside these as well, since it is what lets the cache compute a new freshness lifetime for the body it already has on disk — without it, the cache has no way to know how much longer the existing copy is good for.
Headers that describe the body itself — Content-Length, Content-Type, Content-Encoding — are meaningless on a 304 because there is no body, and should not be resent. A common bug is an origin or intermediary that forwards Content-Length: 0 on a 304; this is harmless in practice (clients correctly interpret it as “no body”) but is not itself a requirement of the spec, and forwarding the original Content-Length from the 200 case would be actively wrong.
Scope and Precedence
When multiple conditional headers appear in the same request, RFC 9110 §13.2.2 defines a strict general precedence — evaluate top to bottom, and stop at the first applicable rule:
| Order | Header | Governs | RFC 9110 |
|---|---|---|---|
| 1 (highest) | If-Match |
State-changing requests; strong comparison only | §13.1.1 |
| 2 | If-Unmodified-Since |
Evaluated only when If-Match is absent |
§13.1.4 |
| 3 | If-None-Match |
Read revalidation (GET/HEAD); weak comparison allowed |
§13.1.2 |
| 4 (lowest) | If-Modified-Since |
Evaluated only when If-None-Match is absent |
§13.1.3 |
The practical consequence for the read path: if a request carries both If-None-Match and If-Modified-Since, the origin evaluates If-None-Match and ignores If-Modified-Since completely — even if the date it carries is stale or malformed. This matters because HTTP client libraries and browsers routinely send both headers together (a stored representation typically carries both an ETag and a Last-Modified value), and an origin that mistakenly checks the date first can return a 200 when it should have returned a 304, or vice versa.
The scope split also matters for cache tiers. Conditional revalidation is something every tier can do independently:
- Browser private cache issues its own conditional request to whatever it talks to next — a CDN edge or the origin directly — once its local copy’s
max-ageexpires. - CDN / shared cache may answer the browser’s conditional request itself if its own copy is still fresh, without ever contacting the origin. If its copy has expired, it issues its own upstream conditional request using its stored validator, which may differ in timing from the browser’s.
- Origin is the final authority: it computes the current validator from the live representation and is the only tier that can definitively say whether a match holds.
Diagram: Conditional Request Decision Flow at Origin
Implementation Patterns
Pattern 1: Static asset — browser-driven revalidation
Hashed filenames make revalidation almost unnecessary (the URL changes when content changes), but unhashed assets still benefit from a strong ETag:
GET /assets/site.css HTTP/1.1
Host: example.com
If-None-Match: "9f8c2e-2210"
HTTP/1.1 304 Not Modified
ETag: "9f8c2e-2210"
Cache-Control: public, max-age=3600
Date: Mon, 06 Jul 2026 10:00:00 GMT
Pattern 2: API resource — ETag over Last-Modified
JSON API responses often lack a natural “modified time” but always have a serializable body, making a content-derived ETag the more reliable validator:
GET /api/orders/48213 HTTP/1.1
Host: api.example.com
Accept: application/json
If-None-Match: "33a64df551425fcc55e4d42a148795d9f25f89d4"
HTTP/1.1 304 Not Modified
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
Cache-Control: private, max-age=0, must-revalidate
Vary: Accept-Encoding
Date: Mon, 06 Jul 2026 10:00:00 GMT
must-revalidate alongside max-age=0 forces a conditional check on every request while still letting that check resolve to a cheap 304 — the client re-verifies every time but rarely re-downloads.
Pattern 3: Authenticated endpoint — conditional headers don’t apply
Responses scoped to a single authenticated session should not carry validators at all, because they should not be stored anywhere a conditional request could target them:
GET /api/account/session HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGciOi...
HTTP/1.1 200 OK
Cache-Control: private, no-store
Content-Type: application/json
Emitting an ETag here is harmless but pointless — no-store means no cache retains the representation to compare against later, so a subsequent request has nothing to send If-None-Match against.
Pattern 4: CDN split — edge revalidates to origin on expiry
A CDN answers the client directly while its own copy is fresh, and only opens a conditional round-trip to the origin once its s-maxage window closes:
# Edge -> origin, issued only after the edge's stored s-maxage expires
GET /api/catalog/summary HTTP/1.1
Host: origin.example.com
If-None-Match: "edge-stored-etag-value"
# Origin -> edge
HTTP/1.1 304 Not Modified
ETag: "edge-stored-etag-value"
Cache-Control: public, s-maxage=120, stale-while-revalidate=30
Date: Mon, 06 Jul 2026 10:02:00 GMT
The edge then resets its own freshness clock and answers the waiting client — or, if it was already serving stale content during revalidation, swaps the background fetch’s result in for subsequent requests.
Pattern 5: State-changing write — If-Match as a lost-update guard
PUT /api/documents/771 HTTP/1.1
Host: api.example.com
If-Match: "doc771-v14"
Content-Type: application/json
{...updated document body...}
HTTP/1.1 412 Precondition Failed
Content-Type: application/json
{"error": "document has changed since it was last read"}
The client read version v14, but the stored resource has since moved to v15 (or beyond). Returning 412 instead of silently applying the write prevents the client from overwriting someone else’s intervening change — the write-path counterpart to the read-path 304.
Server and CDN Configuration
Nginx
# Static assets: emit a strong ETag automatically, let Nginx handle
# both If-None-Match and If-Modified-Since comparisons natively.
location /assets/ {
etag on;
if_modified_since exact;
add_header Cache-Control "public, max-age=3600";
}
# Reverse-proxied API: forward the origin's validators unchanged and
# have Nginx issue its own conditional request upstream when its
# cached copy needs revalidating, instead of re-fetching the full body.
location /api/ {
proxy_pass http://app_upstream;
proxy_cache api_cache;
proxy_cache_valid 200 304 60s;
proxy_cache_revalidate on;
proxy_cache_use_stale error timeout updating;
}
proxy_cache_revalidate on is what makes Nginx send If-Modified-Since / If-None-Match to the upstream when a cached entry has expired, rather than issuing a plain unconditional GET.
Apache
# FileETag drives ETag generation from inode/mtime/size — see the
# cross-server instability note in Failure Modes below.
FileETag MTime Size
Header set Cache-Control "public, max-age=3600"
# mod_proxy forwards client validators to the upstream unchanged
# and relays the upstream's 304 back to the client verbatim.
ProxyPass "http://app-upstream/api/"
ProxyPassReverse "http://app-upstream/api/"
ProxyPreserveHost On
Cloudflare
Cloudflare answers a client’s conditional request itself whenever its edge copy is fresh, comparing the client’s If-None-Match against its own stored ETag without contacting the origin at all. When the edge copy has expired, Cloudflare forwards the client’s validators upstream as its own conditional request and relays the origin’s decision. No special configuration is required for this baseline behavior as long as the origin emits ETag or Last-Modified on every cacheable response — Cloudflare only builds a validator-comparison path for responses it can legally store, meaning public responses with an explicit TTL.
Confirm the setting that controls whether Cloudflare caches at all for a given path (a prerequisite for any edge-level revalidation):
curl -X PATCH "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/settings/cache_level" \
-H "Authorization: Bearer $CF_TOKEN" \
-H "Content-Type: application/json" \
-d '{"value": "cache_everything"}'
If the origin’s validators are stripped by an intervening proxy before Cloudflare ever sees them, the edge falls back to pure TTL-based expiry with no conditional-revalidation option at all — see Failure Modes below.
Interaction with Related Directives
ETag and If-None-Match — the ETag an origin emits on a 200 response is exactly the value a well-behaved client echoes back as If-None-Match on the next request. If the origin’s ETag generation is unstable (changes on every request for identical content), every conditional request fails to match and every revalidation becomes a full 200 — silently defeating the entire mechanism without any error being raised anywhere.
stale-while-revalidate and conditional requests — stale-while-revalidate lets a cache serve its expired copy to the current client immediately while a background request — itself typically conditional, carrying the expired entry’s validator — checks with the origin. If that background check comes back 304, the cache simply extends the freshness of the copy it already served; no second round-trip to the client is needed.
no-cache and conditional requests — no-cache is the directive that requires this whole mechanism to run on every single request: it permits storage but forbids reuse without revalidation. A no-cache response without any validator at all is a contradiction in practice — the cache is told “always check first” but given nothing to check with, so it degrades to a full re-fetch every time.
max-age / s-maxage and conditional requests — freshness lifetimes determine when a cache is required to revalidate; conditional requests determine how cheaply that revalidation can complete. A long max-age with no validators at all means the cache goes uncontested for a long time, then falls back to a full unconditional fetch the moment it expires.
Vary and validator scope — a single URL fragmented by Vary into multiple stored variants must produce a distinct ETag per variant. A gzip-encoded variant and a brotli-encoded variant of the same logical resource are not byte-identical, so sharing one ETag across them causes a client that negotiated one encoding to get a false 304 match against a representation it never actually received.
Verification Workflow
Step 1 — Capture the current validator
curl -sI https://example.com/api/products/9472 \
| grep -iE 'etag|last-modified|cache-control'
Step 2 — Confirm a matching validator produces 304
curl -s -o /dev/null -w '%{http_code}\n' \
-H 'If-None-Match: "33a64df551425fcc55e4d42a148795d9f25f89d4"' \
https://example.com/api/products/9472
Expected: 304.
Step 3 — Confirm the 304 body is empty
curl -sD - -o /tmp/body.bin \
-H 'If-None-Match: "33a64df551425fcc55e4d42a148795d9f25f89d4"' \
https://example.com/api/products/9472 > /dev/null
wc -c < /tmp/body.bin
Expected: 0.
Step 4 — Confirm the If-Modified-Since fallback
curl -sI -H 'If-Modified-Since: Mon, 06 Jul 2026 09:00:00 GMT' \
https://example.com/api/products/9472
Expected: 304 if the resource has not changed since that timestamp, 200 otherwise.
Step 5 — Confirm precedence when both headers are sent
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, even though the If-Modified-Since date is decades old — this confirms the origin evaluated If-None-Match and, per RFC 9110 §13.1.3, ignored If-Modified-Since entirely rather than treating “any changes since 1998” as true and returning 200.
Failure Modes and Gotchas
- No validators emitted at all — the origin never sets
ETagorLast-Modified, so no client can ever send a meaningful conditional request. Every revalidation becomes a full200. This is the single most common cause of “why isn’t my cache validating.” - ETag instability across load-balanced servers — an
ETagderived from inode number or filesystemmtime(Apache’s defaultFileETag INode MTime Size) differs per server behind a load balancer even for byte-identical files, soIf-None-Matchfails intermittently depending on which server handles the request. Use a content hash instead, or setFileETag MTime Size(dropINode) at minimum. - Intermediate proxy or WAF strips conditional headers — some security appliances strip
If-None-Match/If-Modified-Sinceon the way to the origin as a defensive measure, silently forcing every request to be unconditional. Confirm headers actually arrive at the origin, not just that the client sent them. - Weak vs. strong comparison confusion —
If-MatchandIf-Unmodified-Sincerequire strong comparison and must never be satisfied by aW/-prefixed weak ETag;If-None-Matchis permitted to use weak comparison. An origin that applies strong comparison rules everywhere will reject validIf-None-Matchmatches against weak ETags and needlessly return full200bodies. - Vary mismatch produces a false 304 — if a resource has encoding- or language-specific variants but shares one
ETagacross all of them, a client can receive a304telling it to reuse a variant it never actually fetched (wrong encoding, wrong language). Scope the validator to the fullVarydimension set. - Clock skew breaks
If-Modified-Since— because it is date-based rather than opaque,If-Modified-Sinceis sensitive to clock drift between origin and client/cache; a few seconds of skew can cause spurious200s or spurious304s at the boundary.ETag-based validation is immune to this class of bug. - Cross-origin fetches drop conditional headers — some browser fetch configurations and older HTTP client defaults omit
If-None-Matchon cross-origin requests unless credentials/cache mode are explicitly configured, causing repeated full downloads that look like a server-side bug but originate client-side. - 304 carrying a stale
Content-Length— forwarding the previous response’sContent-Lengthheader verbatim on a304is unnecessary and can confuse strict parsers; per RFC 9110 §15.4.5 a304has no body and ends at the blank line after headers regardless of any length value present.
If revalidation is happening but producing the wrong outcome — origins that never return 304, or clients stuck serving stale content — the step-by-step reproduction workflow in How to Debug 304 Not Modified Responses walks through isolating which of these failure modes is in play.
Frequently Asked Questions
Does a 304 Not Modified response ever include a message body?
No. RFC 9110 §15.4.5 states plainly that a 304 response cannot contain a message body — it is terminated by the first empty line after the header fields. Any bytes after that point are not part of a compliant response.
Which headers must an origin send with a 304 response?
Any of Content-Location, Date, ETag, Expires, and Vary that would have appeared in the equivalent 200 OK. Most implementations also resend Cache-Control, since caches need it to recompute freshness for the body they already hold.
What happens when both If-None-Match and If-Modified-Since are present?
The server evaluates If-None-Match and ignores If-Modified-Since completely, regardless of what date it carries. This is a hard requirement in RFC 9110 §13.1.3, not an implementation choice.
Can If-Match be satisfied by a weak ETag?
No. If-Match and If-Unmodified-Since require strong comparison, so a weak validator (W/"...") never satisfies them. If-None-Match, used for read-path revalidation, permits weak comparison.
Do CDNs revalidate on behalf of the origin, or just pass the request through?
Both, depending on cache state. A CDN with a fresh stored copy answers the client’s conditional request directly using its own validator. Once that copy expires, the CDN issues its own conditional request upstream and relays the origin’s decision back to the client.
Related
- ETag vs Last-Modified: Which Validator to Use — a direct comparison of the two validator types this page’s conditional headers are built around.
- Strong vs Weak ETags Explained — the comparison-function distinction that determines which conditional headers a given ETag can legally satisfy.
- Last-Modified and Date-Based Validation — a deeper look at how
Last-Modifiedtimestamps are generated and compared forIf-Modified-Since/If-Unmodified-Since. - Header Stacking and Directive Precedence — the broader precedence framework that governs how
Cache-Controldirectives interact once a conditional request resolves.