Short answer: 304 Not Modified is not an error. It answers a conditional request: the browser or a CDN already holds a copy and asks whether it changed, sending the copy’s ETag or date. A 304 means “no, use your copy”, without a body. It only causes trouble when a cache keeps a file that did change.
url=https://example.com/assets/app.css
# 1. Which validators does the server send?
curl -sI "$url" | grep -i -E '^(HTTP|etag|last-modified|cache-control)'
# 2. Ask again with the ETag (copy it exactly, quotes included)
curl -sI -H 'If-None-Match: "6ad73114-bc55"' "$url" | head -n 1
# 3. Or with the date
curl -sI -H 'If-Modified-Since: Tue, 20 Oct 2026 09:15:00 GMT' "$url" | head -n 1A 304 in the second or third step means revalidation works. A 200 means the server ignores the validator or the value did not match; both are covered below.
How a conditional request works
- 1The first response carries validatorsThe server answers
200with the file and at least one validator: anETag(an opaque version tag) or aLast-Modifieddate, often both, plusCache-Controlrules that say how long the copy stays fresh. - 2While the copy is fresh, nothing is sentWithin
max-agethe browser uses its copy without asking. No request, no 304. - 3When it is stale, the client asks conditionallyThe browser or CDN repeats the request with
If-None-Match(the ETag) andIf-Modified-Since(the date). - 4The server answers 304 or 200Unchanged:
304 Not Modified, headers only, and the copy is fresh again. Changed: a normal200with the new file and new validators.
| Response header | Request header | How the server compares |
|---|---|---|
ETag: "6ad73114-bc55" | If-None-Match | Same tag, quotes included, means unchanged. The comparison is weak: W/"6ad73114-bc55" matches the strong tag too |
Last-Modified: Tue, 20 Oct 2026 09:15:00 GMT | If-Modified-Since | Not changed since that date means unchanged; one-second resolution |
If a request carries both, RFC 9110 says the server must ignore If-Modified-Since and decide on the ETag alone. Python’s built-in server shows all of this on a local file. These runs are from Debian 12 with Python 3.11.2 and curl 7.88.1:
$ curl -sI http://127.0.0.1:18304/app.css
HTTP/1.0 200 OK
Server: SimpleHTTP/0.6 Python/3.11.2
Date: Wed, 21 Oct 2026 19:48:03 GMT
Content-type: text/css
Content-Length: 17
Last-Modified: Tue, 20 Oct 2026 09:15:00 GMT
$ curl -sI -H 'If-Modified-Since: Tue, 20 Oct 2026 09:15:00 GMT' http://127.0.0.1:18304/app.css
HTTP/1.0 304 Not Modified
Server: SimpleHTTP/0.6 Python/3.11.2
Date: Wed, 21 Oct 2026 19:48:03 GMTThe same request with an extra If-None-Match: "x" header got a 200. Python’s server sends no ETag, so it cannot match one, and it skips the date check whenever If-None-Match is present, as the RFC requires. curl can also build the date for you. -z takes a date or a local file, whose modification time it sends as If-Modified-Since; both returned 304 here.
curl -s -o /dev/null -w '%{http_code}\n' -z 'Tue, 20 Oct 2026 09:15:00 GMT' "$url"
curl -s -o /dev/null -w '%{http_code}\n' -z ./app.css "$url"
# ETag round trip: save it, then send it back as If-None-Match
curl -s -o /dev/null --etag-save /tmp/etag.txt "$url"
curl -s -o /dev/null -w '%{http_code}\n' --etag-compare /tmp/etag.txt "$url"-I sends a HEAD request, which nginx answers with the same 304 logic as a GET. More flags are in the curl examples guide.
Without a terminal, the HTTP header checker shows a URL’s ETag, Last-Modified and Cache-Control headers as seen from outside your network.
304 vs memory cache in DevTools
The Network tab shows three different things that are easy to mix up:
| Network tab shows | What happened | Server contacted? |
|---|---|---|
| 200, size (memory cache) or (disk cache) in Chrome; cached in Firefox | The copy was fresh and used as is | No |
| 304 with a few hundred bytes transferred | The copy was stale, the server confirmed it is unchanged | Yes, headers only |
| 200 with the full size | No copy, or the file changed, or the copy could not be revalidated | Yes, full download |
Your JavaScript does not see the 304. When fetch() revalidates a cached response, the page receives the stored copy with status 200; only a request that set If-None-Match itself gets the raw 304. To see what a first-time visitor gets, tick Disable cache in DevTools or use a hard reload (Ctrl+Shift+R, on a Mac Cmd+Shift+R), which asks for everything again without validators.
When 304 causes trouble
Old CSS or JavaScript after a deploy
The most common complaint is not a 304 at all. A file that is still fresh by its max-age is never revalidated, so the browser keeps the old one without asking. Without any Cache-Control or Expires header, RFC 9111 lets caches guess a lifetime from Last-Modified, typically 10 % of the file’s age: a stylesheet last changed 30 days ago may be reused for three days without a single request.
The fix is to make the name change when the content does. Build tools put a content hash into the file name (app.3f9a1c2b.css), the HTML links the new name after every deploy, and the hashed files can be cached for a year. The HTML itself is revalidated on every visit.
A 304 for a file that did change
Servers build validators from file metadata, not content. nginx’s ETag is the modification time and the size in hex ("6ad73114-bc55" is 20 Oct 2026 09:15 UTC and 48,213 bytes); Apache 2.4’s default FileETag MTime Size uses the same two. If a build pins every file to one fixed date and an edit keeps the size, both validators stay the same and the server keeps answering 304 for content it no longer has. A rollback to an older timestamp is the second trap, on servers that answer 304 for anything not newer than the client’s date. Python’s server is one of them:
# the browser holds the version from 20 Oct 09:15
$ printf 'body{color:#0a0}\n' > www/app.css
$ touch -d '2026-10-18 12:00:00 UTC' www/app.css # older build, timestamp kept
$ curl -sI -H 'If-Modified-Since: Tue, 20 Oct 2026 09:15:00 GMT' http://127.0.0.1:18306/app.css | head -n 1
HTTP/1.0 304 Not Modifiednginx compares dates for exact equality by default (if_modified_since exact), so a rollback gets a 200 there; the setting before brings back Python’s behaviour. Tools that keep timestamps on purpose include tar when it unpacks, cp -p and rsync -a. Hashed file names avoid the whole question.
ETags behind a load balancer
Two web servers behind a load balancer each compute their own ETag. If the same file has different timestamps on each, because each server checked out or unpacked the release at its own time, the tags differ, and every request that lands on the other server gets a full 200 instead of a 304. Nothing breaks, but caching stops saving bandwidth. Copy one build to all servers with its timestamps (rsync -a from the build machine), or let the app send content-based ETags. Apache 2.2 also put the inode number into its default ETag, which differs on every server; 2.4 dropped it.
Compression changes the ETag
Since nginx 1.7.3, gzip and other filters that change a response turn a strong ETag into a weak one (W/"…"), which still works for If-None-Match. Apache’s mod_deflate appends the compression method to the ETag instead, and its documentation says that this default prevents 304 answers for compressed content. Since Apache 2.4.58, DeflateAlterETag NoChange or Remove changes that.
Cached HTML that points at deleted files
If HTML pages carry a long max-age, browsers and CDNs keep serving the old page after a deploy, and the old page asks for hashed assets you just deleted. Send Cache-Control: no-cache on HTML, which still allows a cheap 304 on every visit, and keep the previous release’s assets online for a day or two.
Set it up in nginx
For static files nginx sends ETag and Last-Modified and answers 304 on its own: etag on and if_modified_since exact are the defaults. What you add is the caching policy:
# Hashed build output: cache for a year, never revalidate
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
# HTML and unhashed files: keep a copy, but ask every time (cheap 304s)
location / {
add_header Cache-Control "no-cache";
try_files $uri $uri/ =404;
}sudo nginx -t && sudo systemctl reload nginxadd_headerinherits all or nothing by default. Oneadd_headerin alocationdrops everyadd_headerfrom theserverlevel for that location, security headers included. Repeat them, or keep them in a snippet you include.- Vite puts hashed files in
/assets/, Next.js in/_next/static/. Use the directory your build tool writes to, and never give unhashed files the year-long rule. - Dynamic pages set their own validators. nginx creates 304s for static files and for responses from its own
proxy_cache. Otherwise the PHP, Node or Python app behind it must send an ETag orLast-Modifiedand compare it; Express, for example, sends weak ETags by default.
CDNs and revalidation
A CDN revalidates exactly like a browser: when its copy expires, it sends a conditional request to your server, and a 304 renews the copy without transferring the file again. The CDN guide lists the Cache-Control values that decide how long each layer keeps a copy. After a deploy without hashed names, purge the changed URLs, or the CDN serves the old file until its copy expires.
Redirects follow the same caching rules: a 301 is cached by browsers even without headers, which is why a wrong one sticks.
Does 304 matter for SEO?
It helps. Google’s crawler documentation (page updated 12 June 2026, read 21 October 2026) says its crawlers support both ETag with If-None-Match and Last-Modified with If-Modified-Since, and recommends ETag because it has no date formatting pitfalls. A 304 tells Googlebot a page is unchanged without sending it again, which saves crawl capacity on large sites, and the Crawl stats report in Search Console counts 304 answers separately. Make sure a changed page gets a new ETag; a validator that never changes hides your updates from crawlers the same way it hides them from browsers.
On your own VPS or dedicated server the whole policy is one Cache-Control line per location, and the curl commands at the top show whether the 304s arrive.
Frequently asked
Is 304 Not Modified an error?
No. It is the normal answer to a conditional request: the browser or a CDN asked whether its cached copy changed, and the server said it did not, so the client uses its copy. A 304 only points at a problem when the file did change and the server's validator, its ETag or Last-Modified date, did not.
What is the difference between 304 and 200 from memory cache?
200 from memory or disk cache means the browser used its copy without contacting the server, because the copy was still fresh. 304 means the copy was stale, the browser asked the server with If-None-Match or If-Modified-Since, and the server confirmed it was unchanged, sending only headers.
How do I test a 304 response with curl?
Read the ETag with 'curl -sI URL', then repeat the request with an If-None-Match header set to that ETag, copied exactly with its double quotes, or use 'curl -z' with a date to send If-Modified-Since. The first line of the answer should read 304; a 200 means the validator did not match or the server ignores it.
Why do visitors still get old CSS after a deploy?
Usually the old file is still fresh in their cache and is not revalidated at all. Put a content hash in asset file names so every change gets a new URL, cache those for a year, and send Cache-Control: no-cache on HTML so pages are revalidated on every visit.