DEVESSENTIALS

HTTP 304 Not Modified

304 answers a conditional GET or HEAD. The client already has a copy and asks whether it changed, by sending If-None-Match with the ETag it got earlier, or If-Modified-Since with the Last-Modified date. If nothing changed, the server replies 304 with no body and the client uses its stored copy.

3xx · RedirectionRFC 9110 §15.4.5

304 Not Modified

The client's cached copy is still valid, so no body is sent; reuse the cache.

Who fixes it: Nobody (success). 304 means caching works. Issues are server cache configuration: validators that don't change when content does, or no max-age at all.

What 304 means

It sits in 3xx because it redirects the client to its own cache. A 304 must not contain content, but RFC 9110 requires it to include whichever of Cache-Control, Content-Location, Date, ETag, Expires and Vary a 200 would have had, so the cache can refresh its metadata.

A 304 saves bandwidth. It's only a problem when content is stale, or when every request revalidates because nothing has a freshness lifetime.

Source: RFC 9110 §15.4.5.

When a 304 is a problem

  • Users see the old version after a deploy, because the ETag or Last-Modified didn't change when the content did. For example, nginx's ETag is built from modification time and size, so a build that preserves timestamps and size can keep the old ETag.
  • Every asset is revalidated on every page view (lots of 304s in DevTools) because there's no max-age. Each 304 still costs a round trip.
  • Servers behind a load balancer generate different ETags for the same file (older Apache defaults included the inode), so revalidation fails and you get full 200s.
  • Your own code sets If-None-Match on a fetch() call, receives a raw 304, and response.json() fails because there's no body.

How to test and tune revalidation

Test with curl

Get the ETag, then send it back. A 304 with no body means revalidation works. curl has no cache, so without the header you always get 200.

curl -sI https://example.com/app.js | grep -i etag
etag: "65f1a2b3-1c4f"

curl -sI -H 'If-None-Match: "65f1a2b3-1c4f"' https://example.com/app.js
HTTP/2 304

nginx: cache hashed assets for a year, revalidate HTML

Fingerprinted file names never change content, so they don't need revalidation at all. HTML should always revalidate. nginx sends ETag and Last-Modified for static files by default.

location /assets/ {
    # file names contain a content hash
    add_header Cache-Control "public, max-age=31536000, immutable";
}

location / {
    add_header Cache-Control "no-cache";   # store, but revalidate every time
    try_files $uri $uri/ /index.html;
}

Express

Express sends weak ETags by default and answers a matching If-None-Match with 304 automatically. Tune static files with express.static options.

app.use('/assets', express.static('dist/assets', {
  immutable: true,
  maxAge: '1y',
}));

app.set('etag', 'strong'); // or false to disable ETags

Know the two cache directives people mix up

no-cache means "store it, but revalidate before every use", so 304s are expected. no-store means "never store it", so a 304 is impossible.

Headers that matter with 304

ETag / If-None-Match
The validator pair. The server sends ETag; the client echoes it in If-None-Match.
Last-Modified / If-Modified-Since
Date-based validators with one-second resolution. ETags win when both are present.
Cache-Control
max-age decides how long the browser uses its copy without asking. Revalidation (and 304s) only start after that.
Vary
Tells caches which request headers change the response, e.g. Vary: Accept-Encoding.

304 vs similar status codes

  • 200 OK: "200 (from disk cache)" in DevTools means no request was made at all. A 304 means a request was made and the cached body reused.
  • 412 Precondition Failed: The write-side counterpart: If-Match on a PUT failed because the resource changed.
  • 204 No Content: Also has no body, but means an action succeeded, not that a cache is fresh.

SEO impact of 304

Googlebot supports conditional requests with If-None-Match and If-Modified-Since. Answering with 304 for unchanged pages saves crawl resources on large sites. Make sure your server only sends 304 when the content truly hasn't changed, or updates may not get picked up.

Related status codes

Looking for another code? Search the full HTTP status codes list, or read which code your API should return in the REST API status codes guide.

Learn more

Browse all developer guides →

Frequently Asked Questions

Why does fetch() return 200 when DevTools shows 304?▾

The browser's HTTP cache handles revalidation for you. When the server replies 304, the browser combines it with the stored response and hands your JavaScript a 200 with the cached body. You only see a raw 304 in fetch() if you send If-None-Match or If-Modified-Since yourself.

Is a 304 response an error?▾

No. It means the client's cached copy is still current, so the server didn't resend it. Lots of 304s in your logs means caching works. If you want fewer requests altogether, add a max-age so browsers skip revalidation while their copy is fresh.

How do I force a 200 instead of a 304?▾

In DevTools, tick Disable cache or do a hard reload. With curl, just don't send conditional headers. On the server, change the content (and therefore the ETag) or send Cache-Control: no-store.