HTTP status code · Redirection (3xx)
304 Not Modified
304 Not Modified means the copy your browser or cache already holds is still current, so the server skipped sending the body again. It is the normal answer to a conditional request carrying If-None-Match or If-Modified-Since, not an error.
| Class | 3xx, Redirection |
|---|---|
| Defined in | RFC 9110 §15.4.5 |
| Cacheable by default | Not stored itself; it refreshes the cached copy |
| Safe to retry | No need; the client reuses the stored copy it already has |
| Relevant headers |
|
What 304 means
A client holding a stored response can revalidate it by sending back the ETag it received, in If-None-Match, or the Last-Modified date, in If-Modified-Since. If nothing changed, the server answers 304 and the client uses what it has. RFC 9110, section 15.4.5, requires the 304 to carry any of Cache-Control, Content-Location, Date, ETag, Expires and Vary that a 200 would have had, because the cache uses them to update the stored copy.
A 304 ends at the header section: it can never contain content. The saving is bandwidth, not latency, since the client still waits a full round trip for the answer. Skipping the request entirely takes freshness (Cache-Control: max-age), and DevTools then shows 200 with "(memory cache)" or "(disk cache)" instead of a 304.
For search engines a 304 is a shortcut, not a signal: Google's crawlers read it as the content being the same as on the last crawl, and it has no other effect on indexing.
When to use it
- Automatically, for static files: nginx, Apache, express.static and Go’s http.FileServer compare validators and answer 304 without any code from you.
- For API responses that change rarely: send an ETag (a content hash or version number) with Cache-Control: no-cache, so clients check every time but only download when something changed.
- Only for GET and HEAD. When If-None-Match or If-Match fails on PUT, PATCH or DELETE, the answer is 412 Precondition Failed.
Common causes
If you are visiting the site
- Seeing 304 in the Network tab is expected: the browser revalidated a cached file and the server confirmed it is current.
- A page or stylesheet that will not update is usually a long max-age, not a 304; in that case the browser never asked the server at all.
If you run the server
- ETags that differ between servers behind a load balancer (older Apache builds derived them from the file inode), so revalidation never matches and every request returns a full 200.
- Compression or proxies changing validators: nginx turns a strong ETag into a weak one when it gzips the response, and a proxy that strips ETag and Last-Modified disables 304 entirely.
- Front-end code that sets If-None-Match itself in fetch() gets the raw 304 with an empty body and fails parsing JSON; revalidation left to the browser cache would have produced a 200 from cache.
- Validators that do not change when content does, such as an ETag built from a version number nobody bumped, or Last-Modified taken from file timestamps preserved or fixed by the deploy.
How to fix it
If you are visiting the site
- Nothing to fix. If a page looks stale, do a hard reload (Ctrl+Shift+R, or Cmd+Shift+R on macOS), which fetches a full copy instead of revalidating.
If you run the server
- Build ETags from the content (a hash) or from a version that changes on every deploy, never from inode or host-specific data.
- Test revalidation end to end: curl -sI URL to read the ETag, then curl -sI -H 'If-None-Match: "<etag>"' URL and expect a 304.
- In client code, let the browser cache handle validators, or handle status 304 explicitly and reuse your own stored copy.
- Make the 304 carry the same Cache-Control, ETag and Vary the 200 would, or caches will keep stale freshness data.
How to send 304
// res.send() and express.static already answer 304 on a matching
// ETag. Doing it yourself with a version-based ETag:
app.get('/api/catalog', async (req, res) => {
const catalog = await getCatalog();
res.set('ETag', `"${catalog.version}"`);
res.set('Cache-Control', 'no-cache');
if (req.fresh) return res.status(304).end();
res.send(catalog);
});// app/api/catalog/route.ts
export async function GET(request: Request) {
const catalog = await getCatalog();
const etag = `"${catalog.version}"`;
const headers = { ETag: etag, 'Cache-Control': 'no-cache' };
const sent = request.headers.get('if-none-match') ?? '';
if (sent.split(/\s*,\s*/).includes(etag)) {
return new Response(null, { status: 304, headers });
}
return new Response(JSON.stringify(catalog), {
headers: { ...headers, 'Content-Type': 'application/json' }
});
}// http.ServeContent and http.FileServer handle If-None-Match and
// If-Modified-Since for you. The manual version (exact match only):
mux.HandleFunc("GET /api/catalog", func(w http.ResponseWriter, r *http.Request) {
etag := `"` + catalog.Version + `"`
w.Header().Set("ETag", etag)
w.Header().Set("Cache-Control", "no-cache")
if r.Header.Get("If-None-Match") == etag {
w.WriteHeader(http.StatusNotModified) // 304, no body
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(catalog)
})from fastapi import FastAPI, Request, Response
from fastapi.responses import JSONResponse
app = FastAPI()
@app.get("/api/catalog")
def get_catalog(request: Request):
catalog = load_catalog()
etag = f'"{catalog["version"]}"'
headers = {"ETag": etag, "Cache-Control": "no-cache"}
if request.headers.get("if-none-match") == etag:
return Response(status_code=304, headers=headers)
return JSONResponse(catalog, headers=headers)# nginx answers 304 for static files on its own; both
# directives below are already the defaults.
location /assets/ {
etag on;
if_modified_since exact;
add_header Cache-Control "no-cache";
}Commonly confused with
- 304 vs 200
- A "200 (from disk cache)" in DevTools means no request was sent at all; a 304 means the browser asked and the server confirmed the copy.
- 304 vs 412
- 412 is the failed-precondition answer for methods other than GET and HEAD, typically a PUT whose If-Match no longer matches.
Frequently asked questions
- Is 304 Not Modified an error?
- No. It is a successful revalidation: the server is telling the client that the copy it already has is still correct. Monitoring should count it with successes, and it usually means your caching works.
- What is the difference between 304 and 200 from disk cache?
- With 200 (from disk cache) the browser used its copy without contacting the server because it was still fresh. With 304 the copy had expired or required revalidation, so the browser asked and the server answered that it had not changed.
- Why do I get a 304 when the file has changed?
- The validator did not change with the content. Common causes are an ETag derived from a version number that was not bumped, or a Last-Modified date taken from file timestamps that the deploy preserved or fixed. Make validators depend on the content itself.
- Does a 304 response have a body?
- No. RFC 9110 says a 304 is terminated by the end of the header section and cannot contain content or trailers. The client takes the body from its stored response.
Last reviewed by Arielton Oberek.