304 Not Modified
Redirection response defined by RFC 9110 §15.4.5.
Last updated July 27, 2026
Common causes at a glance
- A browser revalidating an asset whose max-age has expired
- A CDN forwarding If-None-Match to the origin on a stale-but-revalidatable object
- A crawler sending If-Modified-Since to avoid re-downloading unchanged pages
- A weak ETag matching after a gzip or minification step changed the bytes
Reproduce it
curl -sSI -H 'If-None-Match: "abc123"' https://cdn.example.com/app.cssWhat 304 Not Modified means
The client sends If-None-Match with an ETag it
stored earlier, or If-Modified-Since with a timestamp. If the
representation has not changed the server answers 304 and the client serves its cached
copy, saving the entire body.
Two details bite people. First, a 304 is not an error and not a cache miss — it means
revalidation succeeded. A dashboard full of 304s on static assets describes a cache
that is working, though it also suggests those assets should be fingerprinted and
served with a long max-age so the client never asks. Second, a 304
refreshes the stored response’s freshness, so the same validator is reused repeatedly
and nothing is re-downloaded.
What the spec says
RFC 9110 §15.4.5 requires a 304 to carry any of
Content-Location, Date, ETag,
Vary, Cache-Control, and Expires a 200 would
have sent, and forbids content and trailers entirely — the response ends at the header
section. Validators are §8.8, conditional request fields
§13.1, and precedence when a request carries several
§13.2.2. RFC 9111 §4.3.4 defines how a
cache freshens a stored response from a 304.
What actually causes it
The interesting failures are 304s that should not happen and 200s
that should have been 304s. A server generating its ETag from the file
inode, as older Apache configurations did, produces a different validator on every
origin in a fleet, so a client landing on another backend revalidates and gets a full
200. Proxies that gzip on the fly and then weaken or strip the ETag do the
same, and a CDN-wide revalidation storm after a deploy is the visible symptom. In the
other direction, an application deriving its ETag from data that does not cover the
whole representation returns 304 for content that changed, and users see stale pages no
reload fixes. A wrong Vary is behind plenty of both.
How to debug it
Fetch once, capture the validator, then replay it:
etag=$(curl -sSI https://cdn.example.com/app.css | awk -F': ' '/^[Ee][Tt]ag/{print $2}' | tr -d '\r')
curl -sSI -H "If-None-Match: $etag" https://cdn.example.com/app.css | head -1
A correct server returns 304 on the second call. A 200 with an identical
body means the validator is unstable — compare the ETag across several
requests, and if it moves, check for multiple origins or an on-the-fly compressor.
If a 304 is served for content that did change, the problem is upstream of the client.
Purge the CDN object, then verify with the header inspector
that Vary lists every header the response depends on. Watch for 304s
carrying Content-Length or a body: some clients hang waiting for bytes
that never arrive.
Working examples
curl
# Capture the validator, then replay it as a conditional request.
etag=$(curl -sSI https://cdn.example.com/app.css \
| awk -F': ' '/^[Ee][Tt]ag/{print $2}' | tr -d '\r')
curl -sSI -H "If-None-Match: $etag" https://cdn.example.com/app.css | head -1
# expect: HTTP/2 304
# Date-based validation, useful when the server has no ETag:
curl -sSI -H 'If-Modified-Since: Mon, 27 Jul 2026 00:00:00 GMT' \
https://cdn.example.com/app.css | head -1
Python (requests)
import requests
first = requests.get("https://cdn.example.com/app.css", timeout=10)
etag = first.headers.get("ETag")
last_modified = first.headers.get("Last-Modified")
second = requests.get(
"https://cdn.example.com/app.css",
headers={"If-None-Match": etag} if etag
else {"If-Modified-Since": last_modified},
timeout=10,
)
print(second.status_code, len(second.content)) # 304 and 0 bytes
Node (fetch)
// A 304 has no body; resp.ok is false, which is not an error here.
const first = await fetch("https://cdn.example.com/app.css");
const etag = first.headers.get("etag");
await first.arrayBuffer();
const second = await fetch("https://cdn.example.com/app.css", {
headers: etag ? { "If-None-Match": etag } : {},
cache: "no-store", // stop the runtime from answering from its own cache
});
console.log(second.status, second.headers.get("etag"));
// 304 — treat it as success and reuse whatever you stored earlier.Reference and tooling
The authoritative list of assigned status codes is theIANA HTTP Status Code Registry. For a searchable copy with the semantics summarised, use theHTTP status code reference on this site. To see exactly what a server is sending, theheader inspector shows the raw status line and response headers, and thecurl builder assembles the request flags for you.