304 Not Modified

The resource has not changed since the version specified by the request’s conditional headers — use the cached copy.

Quick Reference

Category3xx Redirection
RFCRFC 9110, Section 15.4.5
Has bodyNo — response MUST NOT include a message body
CacheableYes — its entire purpose is to enable caching
TriggerIf-None-Match or If-Modified-Since header in the request

What 304 Not Modified Means

HTTP 304 Not Modified is the server’s response to a conditional GET request when the resource has not changed since the client last fetched it. The client sends a conditional request with a validator — an ETag or a Last-Modified timestamp — asking “has this changed since I last got it?” A 304 response answers “no, use your cached copy.”

304 responses have no body. The entire point is to avoid re-transmitting a resource the client already has. The bandwidth savings are significant: a 304 response might be 200 bytes of headers instead of 50 KB of JSON or 500 KB of JavaScript.

304 falls in the 3xx range, which can be confusing since it is not a redirect. The 3xx category in HTTP covers “redirection” broadly, and 304 fits as “use a different source (your cache) rather than this response.”

How Conditional Requests Work

There are two types of validators used in conditional requests:

ETags (Entity Tags)

An ETag is an opaque string that uniquely identifies a version of a resource. Strong ETags ("abc123") require byte-for-byte equality. Weak ETags (W/"abc123") indicate semantic equivalence even if bytes differ.

# Initial request
GET /api/users/7 HTTP/1.1

HTTP/1.1 200 OK
ETag: "v3-abc123"
Cache-Control: max-age=300
Content-Type: application/json

{"id": 7, "name": "Ced"}

# Revalidation request after cache expires
GET /api/users/7 HTTP/1.1
If-None-Match: "v3-abc123"

HTTP/1.1 304 Not Modified
ETag: "v3-abc123"
Cache-Control: max-age=300

The server checks whether its current ETag for the resource matches the submitted value. Match → 304. No match → 200 with new content and new ETag.

Last-Modified / If-Modified-Since

# Initial request
GET /static/app.js HTTP/1.1

HTTP/1.1 200 OK
Last-Modified: Sat, 20 Apr 2024 10:00:00 GMT
Cache-Control: max-age=3600

[JavaScript content]

# Revalidation
GET /static/app.js HTTP/1.1
If-Modified-Since: Sat, 20 Apr 2024 10:00:00 GMT

HTTP/1.1 304 Not Modified
Last-Modified: Sat, 20 Apr 2024 10:00:00 GMT

The server compares the request’s If-Modified-Since timestamp against the resource’s last modification time. If the resource has not changed since that time, return 304.

ETags are preferred over Last-Modified because timestamps have one-second granularity, which can cause issues when multiple changes happen within a second or when clock skew exists between servers.

Headers Included with 304

A 304 response has no body but should include the same headers that would have appeared on a 200 response for the same resource. RFC 9110 specifies which headers must be included:

  • ETag — the current ETag of the resource (to refresh the cached ETag)
  • Cache-Control — updated caching directives (to extend or shorten the cache lifetime)
  • Vary — which request headers affect caching (e.g., Vary: Accept-Encoding)
  • Expires — if used instead of Cache-Control
  • Content-Location, Date, Age — if present on the original 200

These headers allow the cache to update its stored metadata (new cache expiry, new ETag) without receiving the full body again.

Implementing 304 in Common Frameworks

Express.js

app.get('/api/posts/:id', async (req, res) => {
  const post = await Post.findById(req.params.id);
  if (!post) return res.sendStatus(404);

  const etag = `"${post.version}-${post.updatedAt.getTime()}"`;

  // res.set + res.sendStatus handles conditional logic automatically
  res.set('ETag', etag);
  res.set('Cache-Control', 'private, max-age=300');

  if (req.headers['if-none-match'] === etag) {
    return res.sendStatus(304);
  }

  res.json(post);
});

nginx (static files)

Nginx handles 304 for static files automatically. It sends ETag and Last-Modified headers on 200 responses and returns 304 when conditional request headers match. No configuration is required beyond serving static files normally.

Cloudflare

Cloudflare’s CDN layer handles 304 responses for cached content automatically. When a browser sends a conditional revalidation request for a resource in Cloudflare’s cache, Cloudflare checks its stored ETag or Last-Modified against the request headers and returns 304 without contacting the origin server.

304 vs Related Status Codes

CodeHas bodyMeaning
200 OKYesResource found; body contains current representation
304 Not ModifiedNoResource unchanged; use cached copy
412 Precondition FailedUsuallyConditional request for modification (PUT/DELETE) failed because resource state has changed
410 GoneOptionalResource permanently deleted; remove from cache

Frequently Asked Questions

Why is 304 in the 3xx range instead of 2xx?

The HTTP specification groups 304 with 3xx because the client is effectively redirected to an alternative source — its own cache — rather than receiving a fresh response body. It is a quirk of HTTP history; 304 was added early and the grouping made semantic sense to the original authors even if it confuses developers today.

Can a 304 response refresh the cache expiry?

Yes. If the 304 response includes a new Cache-Control: max-age=N header, the cache updates its TTL from the time of the 304 response. This is one of the main reasons to include Cache-Control on 304 responses rather than omitting it.

What happens if the server sends 304 but the client has no cached copy?

The client has no representation to use, so it will typically treat the 304 as an error and either re-request without conditional headers or show an error. This situation (a 304 response when no conditional headers were sent) is a server bug — 304 must only be sent in response to a conditional request.

Does 304 work with POST requests?

No. 304 is only valid for GET and HEAD requests. Conditional headers on POST, PUT, and DELETE use If-Match and If-Unmodified-Since to prevent lost updates, and the failure response for those is 412 Precondition Failed, not 304.