How HTTP Works
3xx · Redirection
< HTTP/1.1 304 Not Modified

304 Not Modified

Cached response is still valid. Learn how 304 Not Modified improves performance through conditional requests and caching.

Reviewed 6 min readintermediate2 sourcesTry itMarkdown
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 validateStatus predicate, producing AxiosError: 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 === false and no body. During normal browser cache revalidation, Fetch instead updates and returns the stored response. Check response.status before attempting to decode a raw 304 as JSON.
  • Python requests: raise_for_status() returns normally and response.ok is True for 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. -L adds no cache lookup: 304 supplies no redirect destination.
  • .NET: calling EnsureSuccessStatusCode() on 304 throws HttpRequestException. With the English resource string and reason phrase Not Modified, the message is Response 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() and RestTemplate’s default handler reserve their normal HTTP error exceptions for 4xx/5xx. Branch on 304 in your cache code rather than expecting HttpClientErrorException.

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?

  1. No validator. The response has neither ETag nor Last-Modified, so the client has nothing to send. Check your actual response headers; validator generation depends on the server and handler.
  2. ETag differs per server. An explicit Apache FileETag INode MTime Size includes the local inode. Apache 2.4’s default is MTime Size. Remove INode from a cluster’s custom setting and check that modification times match across origins.
  3. Compression rewrites the ETag. nginx weakens an ETag during gzip filtering. Apache’s DeflateAlterETag AddSuffix appends the compression method, and its documentation identifies this as a cause of missing 304s. Compare compressed and uncompressed responses separately; adding W/ and changing the tag value are different operations.
  4. Cache-Control: no-store. Nothing is cached, so there is nothing to revalidate. (no-cache still allows 304s.)
  5. The cache is still fresh. Within max-age the 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.
  6. Vary mismatch. If request headers named in Vary differ, the stored entry is not eligible.
  7. 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-Match and If-Modified-Since produce 304 only for GET and HEAD. For other methods a failed If-None-Match returns 412.
  • Include the validators and caching headers a 200 would carry.
CodeMeaningBody
304Cached copy still validNone
200Changed, or no conditional sentFull
412If-Match/If-Unmodified-Since failedError

More in 304 vs 200.

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

  1. MDN Web Docs: 304 Not Modifieddeveloper.mozilla.org
  2. RFC 9110: 304 Not Modifiedrfc-editor.org

Keep going

Browse /search