HTTP 304 is Not Modified: the client’s cached copy is still current, so the server sends headers and no body, and the client uses what it already has. RFC 9110 section 15.4.5 defines it as the answer to a conditional GET or HEAD that would have been a 200 if its condition hadn’t come out false. It sits among the redirects, but the only place it sends the client is back to its own cache.
The exchange
GET /app.css HTTP/1.1
If-None-Match: "5f2a-18c"
If-Modified-Since: Tue, 13 Oct 2026 08:00:00 GMT
HTTP/1.1 304 Not Modified
ETag: "5f2a-18c"
Cache-Control: max-age=600
Date: Thu, 15 Oct 2026 09:00:00 GMT
The validators come from an earlier 200: its ETag returns in If-None-Match, its Last-Modified in If-Modified-Since. When a request has both, the server must ignore If-Modified-Since (RFC 9110 section 13.1.3). If-None-Match uses weak comparison, so W/"5f2a-18c" matches "5f2a-18c".
What a 304 must carry
No body: a 304 ends with its headers. It must include whichever of Content-Location, Date, ETag and Vary the 200 would have had, and Cache-Control and Expires likewise. The cache merges them into its stored response and treats it as fresh again (RFC 9111 section 4.3.4).
Who sends it
Static servers answer conditional requests on their own: nginx from the file’s modification time and its default ETag, Go’s http.FileServer and http.ServeContent. Express computes a weak ETag for every res.send body and answers 304 when it matches, though your handler still runs and builds the body first. Django needs ConditionalGetMiddleware or the @etag, @last_modified and @condition decorators. Google’s indexing reads a 304 as “same as last crawl”.
Gotchas
- An
ETagthat differs between servers behind a load balancer turns every revalidation into a full 200. - For methods other than
GETandHEAD, a failedIf-None-Matchis 412 Precondition Failed, not 304. - In DevTools, a 304 means the browser asked and was told to reuse its copy. “(disk cache)” or “(memory cache)” means it didn’t ask at all.
- 304 isn’t heuristically cacheable itself; it updates a response the cache already holds.
Cache-Control: no-cachedoesn’t mean “don’t cache”. It means “revalidate first”, and 304 is how that revalidation stays cheap.
In your stack
app.set('etag', 'strong'); // Express: weak ETags by default; false turns them off
@condition(etag_func=lambda request, pk: str(Report.objects.get(pk=pk).version)) # Django
def report(request, pk): ...
w.Header().Set("ETag", `"v42"`)
http.ServeContent(w, r, "report.csv", modTime, file) // answers 304 when If-None-Match or If-Modified-Since matches
location /static/ {
etag on; # the default
expires 10m; # sets Cache-Control: max-age=600, also sent on the 304
}