< HTTP/1.1 304 Not Modified304 Not Modified
Cached response is still valid. Learn how 304 Not Modified improves performance through conditional requests and caching.
- Cacheable
- n/a (interim or validation response)
- Client action
- Use your cached copy
- Usually sent by
- Origin or CDN (conditional request)
- Spec
- RFC 9110 §15.4.5
- Often confused with
- 200
On this page
TL;DR: 304 Not Modified means your cached copy is still valid, so the server sends no response body. Reuse that copy; if it shows stale content, compare the request validators with the current resource before clearing the cache.
What it means
When a cached response goes stale, the client revalidates instead of re-downloading. It sends the validator it saved, and the server replies 304 if the resource still matches (RFC 9110 section 15.4.5, RFC 9111 section 4.3.4).
The HTTP exchange below is illustrative.
GET /app.css HTTP/1.1
Host: example.com
If-None-Match: "abc123"
If-Modified-Since: Fri, 17 Jul 2026 10:00:00 GMT
HTTP/1.1 304 Not Modified
ETag: "abc123"
Cache-Control: max-age=3600
Date: Sun, 04 Oct 2026 12:00:00 GMT
The 304 has no body, and the cache merges its headers into the stored response, refreshing the freshness lifetime. Send Content-Location, Date, ETag, Vary, Cache-Control and Expires when they would appear on the equivalent 200 response.
What you see in your client
A browser’s Network panel can show 304 while JavaScript receives the stored 200 response and its body. The browser’s cache handles that revalidation internally. A raw HTTP client that receives 304 needs its own saved representation.
- Axios: a raw 304 fails the default 200–299
validateStatuspredicate, producingAxiosError: Request failed with status code 304. Check the predicate and message. Accept 304 only when your code retains the corresponding cached body. - fetch: a directly exposed 304 resolves with
response.ok === falseand no body. During normal browser cache revalidation, Fetch instead updates and returns the stored response. Checkresponse.statusbefore attempting to decode a raw 304 as JSON. - Python requests:
raise_for_status()returns normally andresponse.okisTruefor 304. Its source raises only for 4xx/5xx; those properties say nothing about whether you have a saved body to reuse. - curl
-f: 304 passes the failure threshold and has no response body.-Ladds no cache lookup: 304 supplies no redirect destination. - .NET: calling
EnsureSuccessStatusCode()on 304 throwsHttpRequestException. With the English resource string and reason phraseNot Modified, the message isResponse status code does not indicate success: 304 (Not Modified).Check 304 and reuse your saved content before calling the success guard; the text comes from .NET’s resources. - Spring:
WebClient.retrieve()andRestTemplate’s default handler reserve their normal HTTP error exceptions for 4xx/5xx. Branch on 304 in your cache code rather than expectingHttpClientErrorException.
Reproduce it with curl
# 1. Get the validators
curl -sI https://example.com/app.css | grep -iE 'etag|last-modified|cache-control'
# 2. Send them back
curl -sI https://example.com/app.css -H 'If-None-Match: "abc123"'
# HTTP/2 304
# Timestamp variant
curl -sI https://example.com/app.css -H 'If-Modified-Since: Fri, 17 Jul 2026 10:00:00 GMT'
If If-None-Match is present, the server must ignore If-Modified-Since (RFC 9110 section 13.1.3). If you get 200, compare the returned ETag with the one you sent, then check whether the handler processes conditional requests.
Why am I never getting 304?
- No validator. The response has neither
ETagnorLast-Modified, so the client has nothing to send. Check your actual response headers; validator generation depends on the server and handler. - ETag differs per server. An explicit Apache
FileETag INode MTime Sizeincludes the local inode. Apache 2.4’s default isMTime Size. RemoveINodefrom a cluster’s custom setting and check that modification times match across origins. - Compression rewrites the ETag. nginx weakens an ETag during gzip filtering. Apache’s
DeflateAlterETag AddSuffixappends the compression method, and its documentation identifies this as a cause of missing 304s. Compare compressed and uncompressed responses separately; addingW/and changing the tag value are different operations. Cache-Control: no-store. Nothing is cached, so there is nothing to revalidate. (no-cachestill allows 304s.)- The cache is still fresh. Within
max-agethe browser serves from cache without contacting the server (“(disk cache)” in DevTools). A client can revalidate before expiry too. For a clean download in Chrome, use DevTools Network → Disable cache. Varymismatch. If request headers named inVarydiffer, the stored entry is not eligible.- A CDN or middleware strips conditional headers, or your handler ignores them and always builds a full 200.
Implementation
Express
With its default weak ETag setting, Express generates tags for res.send and res.json; its default configuration and response source changes fresh conditional responses to 304. Avoid hand-rolled string equality; req.fresh implements the comparison, including tag lists and *:
app.get('/api/data', (req, res) => {
res.set('Cache-Control', 'private, max-age=0, must-revalidate')
res.json(getData()) // ETag is generated; Express sends 304 when req.fresh
})
nginx
location /static/ {
etag on; # default
if_modified_since exact; # default
expires 1h;
}
PHP
$lastModified = filemtime('data.json');
header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $lastModified) . ' GMT');
$since = isset($_SERVER['HTTP_IF_MODIFIED_SINCE']) ? strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']) : 0;
$method = $_SERVER['REQUEST_METHOD'];
if (($method === 'GET' || $method === 'HEAD')
&& !isset($_SERVER['HTTP_IF_NONE_MATCH'])
&& $since > 0 && $since >= $lastModified) {
http_response_code(304);
exit;
}
if ($method !== 'HEAD') {
readfile('data.json');
}
Common causes by stack
Express: res.json(data) can become 304 when req.fresh matches the generated ETag. Inspect the incoming If-None-Match and outgoing tag before disabling caching. req.fresh returns false for a request carrying Cache-Control: no-cache, which can explain a full 200 during debugging.
nginx / Apache: nginx’s etag on and if_modified_since exact are static-file defaults. Apache uses FileETag MTime Size. If identical deployments produce different tags, compare file modification times and custom ETag settings on each origin.
Django: ConditionalGetMiddleware can replace a matching GET response with HttpResponseNotModified. The condition(etag_func=..., last_modified_func=...) decorator can check validators before building the view; use it when generating the body is expensive.
Spring MVC / Spring Boot: ResponseEntity.ok().eTag(version).body(data) supports conditional validation. WebRequest.checkNotModified(...) lets a controller return early when the validator matches. Ensure the version changes whenever the returned representation changes.
Cloudflare Workers: cache.match(request) can return 304 for matching If-None-Match or If-Modified-Since conditions on a stored response. This lookup stays in the cache; inspect the Worker cache key and validators when the origin has no corresponding request in its logs.
Debugging stale content behind a 304
Compare a conditional request with an unconditional GET to the same URL. Keep Accept-Encoding and any headers named in Vary identical so you compare the same representation. The first command sends a saved tag; the second requests a body without that condition:
curl -si https://example.com/app.css -H 'If-None-Match: "abc123"'
curl -sS -D /tmp/app-css-headers.txt -o /tmp/app.css https://example.com/app.css
grep -iE '^(etag|last-modified|cache-control|vary):' /tmp/app-css-headers.txt
If the second response contains changed bytes but the old tag, fix the origin’s validator generation. Clearing one browser’s cache only removes that client’s saved copy; every other client can continue presenting the same tag. If an application manually added If-None-Match without saving the original body, remove that condition and retrieve the resource before attempting revalidation. Matching tags and the retained representation must travel together in your cache.
Rules for servers
- A 304 has no body; the message ends after the headers.
If-None-MatchandIf-Modified-Sinceproduce 304 only for GET and HEAD. For other methods a failedIf-None-Matchreturns 412.- Include the validators and caching headers a 200 would carry.
304 vs related
| Code | Meaning | Body |
|---|---|---|
| 304 | Cached copy still valid | None |
| 200 | Changed, or no conditional sent | Full |
| 412 | If-Match/If-Unmodified-Since failed | Error |
More in 304 vs 200.
Related
Frequently asked questions
What does 304 Not Modified mean?
A 304 response means the cached version of the resource is still valid. The server tells the client to use its cached copy instead of downloading the resource again.
How does 304 improve performance?
It saves bandwidth by not re-downloading unchanged resources. The client sends If-None-Match or If-Modified-Since headers, and if the resource has not changed, the server returns 304 with no body.
What headers trigger a 304 response?
If-None-Match (with ETag) and If-Modified-Since (with Last-Modified date) are conditional headers that can trigger 304. The server compares these with current resource state.
Does 304 have a response body?
No, 304 responses must not include a body. The client already has the content cached. The response only includes headers to update cache metadata if needed.
Sources
Related
304 Not Modified vs 200 OK
Understand when servers return 304 Not Modified instead of 200 OK. Learn how conditional requests, ETags, and Last-Modified headers enable efficient HTTP caching.
103 Early Hints
The server sends preliminary response headers to help the client start preloading resources. Learn how 103 Early Hints improves page load performance.
300 Multiple Choices
The request has multiple possible responses. Learn when to use 300 Multiple Choices for content negotiation and alternative resource locations.
303 See Other
Redirect to a different resource using GET. Learn when to use 303 to prevent form resubmission and implement the Post-Redirect-Get pattern.
307 Temporary Redirect
Temporary redirect that preserves the HTTP method. Learn when to use 307 instead of 302 for method-sensitive redirects.