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.
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
ETagorLast-Modifieddidn'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-Matchon afetch()call, receives a raw 304, andresponse.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 304nginx: 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 ETagsKnow 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 inIf-None-Match. - Last-Modified / If-Modified-Since
- Date-based validators with one-second resolution. ETags win when both are present.
- Cache-Control
max-agedecides 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-Matchon 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
200OK
The request succeeded, and the response content is the result.
204No Content
The request succeeded and there is intentionally no content in the response.
301Moved Permanently
The resource has a new permanent URL, given in the Location header.
308Permanent Redirect
Permanently moved: repeat this and future requests at the Location URL without changing the method.
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
HTTP Status Codes for REST APIs: 200 vs 201 vs 204, 400 vs 422 vs 429
A practical guide to choosing the right HTTP status code in REST API design. Covers the most confusing pairs: 200 vs 201, 400 vs 422, 401 vs 403, and when to use 503 vs 429.
REST API Design Best Practices: A Practical Guide
The conventions that make REST APIs intuitive and consistent. Covers URL design, HTTP methods, status codes, versioning, pagination, and error responses.
CORS Explained: Why It Happens and How to Fix It
CORS errors happen when a browser blocks a request to a different origin. Learn how the Same-Origin Policy works, what preflight requests are, and how to configure CORS correctly in Express, FastAPI, and Nginx.
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.