# 304 Not Modified

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

Source: https://howhttpworks.com/status-codes/304
Last reviewed: 2026-10-05

> **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.

```http
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](https://github.com/axios/axios/blob/v1.x/lib/defaults/index.js) and [message](https://github.com/axios/axios/blob/v1.x/lib/core/settle.js). 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](https://fetch.spec.whatwg.org/#http-network-or-cache-fetch). 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](https://requests.readthedocs.io/en/latest/_modules/requests/models/#Response.raise_for_status) 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](https://curl.se/docs/manpage.html#-f) 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](https://github.com/dotnet/runtime/blob/main/src/libraries/System.Net.Http/src/System/Net/Http/HttpResponseMessage.cs); the text comes from [.NET's resources](https://github.com/dotnet/runtime/blob/main/src/libraries/System.Net.Http/src/Resources/Strings.resx).
- **Spring:** [`WebClient.retrieve()`](https://docs.spring.io/spring-framework/reference/web/webflux-webclient/client-retrieve.html) and [`RestTemplate`'s default handler](https://github.com/spring-projects/spring-framework/blob/main/spring-web/src/main/java/org/springframework/web/client/DefaultResponseErrorHandler.java) reserve their normal HTTP error exceptions for 4xx/5xx. Branch on 304 in your cache code rather than expecting `HttpClientErrorException`.

## Reproduce it with curl

```bash
# 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`](https://httpd.apache.org/docs/2.4/mod/core.html#fileetag). 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](https://github.com/nginx/nginx/blob/master/src/http/modules/ngx_http_gzip_filter_module.c). Apache's [`DeflateAlterETag AddSuffix`](https://httpd.apache.org/docs/2.4/mod/mod_deflate.html#deflatealteretag) 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](https://developer.chrome.com/docs/devtools/network/reference/#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](https://github.com/expressjs/express/blob/master/lib/application.js) and [response source](https://github.com/expressjs/express/blob/master/lib/response.js) changes fresh conditional responses to 304. Avoid hand-rolled string equality; `req.fresh` implements the comparison, including tag lists and `*`:

```javascript
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

```nginx
location /static/ {
    etag on;                      # default
    if_modified_since exact;      # default
    expires 1h;
}
```

### PHP

```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`](https://expressjs.com/en/5x/api/request/#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`](https://nginx.org/en/docs/http/ngx_http_core_module.html#if_modified_since) are static-file defaults. Apache uses [`FileETag MTime Size`](https://httpd.apache.org/docs/2.4/mod/core.html#fileetag). If identical deployments produce different tags, compare file modification times and custom ETag settings on each origin.

**Django:** [`ConditionalGetMiddleware`](https://docs.djangoproject.com/en/5.2/ref/middleware/#module-django.middleware.http) can replace a matching GET response with `HttpResponseNotModified`. The [`condition(etag_func=..., last_modified_func=...)` decorator](https://docs.djangoproject.com/en/5.2/topics/conditional-view-processing/) 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(...)`](https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-caching.html) lets a controller return early when the validator matches. Ensure the version changes whenever the returned representation changes.

**Cloudflare Workers:** [`cache.match(request)`](https://developers.cloudflare.com/workers/runtime-apis/cache/#match) 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:

```bash
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](https://howhttpworks.com/status-codes/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](https://howhttpworks.com/compare/304-vs-200).

## Related

- [200 OK](https://howhttpworks.com/status-codes/200)
- [412 Precondition Failed](https://howhttpworks.com/status-codes/412)
- [ETag header](https://howhttpworks.com/headers/etag)
- [Last-Modified header](https://howhttpworks.com/headers/last-modified)
- [Cache-Control header](https://howhttpworks.com/headers/cache-control)
