Troubleshooting

304 Not Modified: what the status code means and when it serves stale files

The 304 error code is not an error: the server confirms a cached copy is current. How ETag and Last-Modified work, and how to fix stale files.

Published ~8 min read
On this page

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.

Linux · macOS · WSL · see the validators, then ask conditionally
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 1

A 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

  1. 1The first response carries validators
    The server answers 200 with the file and at least one validator: an ETag (an opaque version tag) or a Last-Modified date, often both, plus Cache-Control rules that say how long the copy stays fresh.
  2. 2While the copy is fresh, nothing is sent
    Within max-age the browser uses its copy without asking. No request, no 304.
  3. 3When it is stale, the client asks conditionally
    The browser or CDN repeats the request with If-None-Match (the ETag) and If-Modified-Since (the date).
  4. 4The server answers 304 or 200
    Unchanged: 304 Not Modified, headers only, and the copy is fresh again. Changed: a normal 200 with the new file and new validators.
Response headerRequest headerHow the server compares
ETag: "6ad73114-bc55"If-None-MatchSame 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 GMTIf-Modified-SinceNot 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:

Example output · python3 -m http.server 18304 --bind 127.0.0.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 GMT

The 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 · conditional requests without copying headers
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 showsWhat happenedServer contacted?
200, size (memory cache) or (disk cache) in Chrome; cached in FirefoxThe copy was fresh and used as isNo
304 with a few hundred bytes transferredThe copy was stale, the server confirmed it is unchangedYes, headers only
200 with the full sizeNo copy, or the file changed, or the copy could not be revalidatedYes, 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:

Debian 12 · Python 3.11.2 · roll back to an older build of app.css
# 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 Modified

nginx 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:

nginx · server block
# 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;
}
test and reload
sudo nginx -t && sudo systemctl reload nginx
  • add_header inherits all or nothing by default. One add_header in a location drops every add_header from the server level 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 or Last-Modified and 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.

Related articles

All troubleshooting