HTTP 304 Not Modified

Not Modified

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 ETag that differs between servers behind a load balancer turns every revalidation into a full 200.
  • For methods other than GET and HEAD, a failed If-None-Match is 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-cache doesn’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
}

Try it in the HTTP Status Codes

304 Not Modified

  1. 304Not Modified3xx Redirection

    The client’s cached copy is still good, so there is no body: the response only refreshes the cache’s idea of how fresh it is.

    When to use it

    Answering a conditional GET whose If-None-Match or If-Modified-Since matches what the client already holds.

    Common mistakes

    Sending it without the validators or cache headers the client needs, and sending a body with it, which is not allowed.

    Not cacheable by defaultRFC 9110

Every code, by class

Codes marked unofficial come from one server’s own convention — nginx, Cloudflare, cPanel, Microsoft — and are not in the IANA registry.

Runs in your browser. Nothing you enter is sent anywhere.