304 Not Modified
The resource has not changed since the version specified by the request’s conditional headers — use the cached copy.
Quick Reference
| Category | 3xx Redirection |
|---|---|
| RFC | RFC 9110, Section 15.4.5 |
| Has body | No — response MUST NOT include a message body |
| Cacheable | Yes — its entire purpose is to enable caching |
| Trigger | If-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
| Code | Has body | Meaning |
|---|---|---|
| 200 OK | Yes | Resource found; body contains current representation |
| 304 Not Modified | No | Resource unchanged; use cached copy |
| 412 Precondition Failed | Usually | Conditional request for modification (PUT/DELETE) failed because resource state has changed |
| 410 Gone | Optional | Resource 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.