Skip to content
Control Plane Labs

304 Not Modified

Redirection response defined by RFC 9110 §15.4.5.

Last updated July 27, 2026

Common causes at a glance

  • A browser revalidating an asset whose max-age has expired
  • A CDN forwarding If-None-Match to the origin on a stale-but-revalidatable object
  • A crawler sending If-Modified-Since to avoid re-downloading unchanged pages
  • A weak ETag matching after a gzip or minification step changed the bytes

Reproduce it

curl -sSI -H 'If-None-Match: "abc123"' https://cdn.example.com/app.css

What 304 Not Modified means

The client sends If-None-Match with an ETag it stored earlier, or If-Modified-Since with a timestamp. If the representation has not changed the server answers 304 and the client serves its cached copy, saving the entire body.

Two details bite people. First, a 304 is not an error and not a cache miss — it means revalidation succeeded. A dashboard full of 304s on static assets describes a cache that is working, though it also suggests those assets should be fingerprinted and served with a long max-age so the client never asks. Second, a 304 refreshes the stored response’s freshness, so the same validator is reused repeatedly and nothing is re-downloaded.

What the spec says

RFC 9110 §15.4.5 requires a 304 to carry any of Content-Location, Date, ETag, Vary, Cache-Control, and Expires a 200 would have sent, and forbids content and trailers entirely — the response ends at the header section. Validators are §8.8, conditional request fields §13.1, and precedence when a request carries several §13.2.2. RFC 9111 §4.3.4 defines how a cache freshens a stored response from a 304.

What actually causes it

The interesting failures are 304s that should not happen and 200s that should have been 304s. A server generating its ETag from the file inode, as older Apache configurations did, produces a different validator on every origin in a fleet, so a client landing on another backend revalidates and gets a full 200. Proxies that gzip on the fly and then weaken or strip the ETag do the same, and a CDN-wide revalidation storm after a deploy is the visible symptom. In the other direction, an application deriving its ETag from data that does not cover the whole representation returns 304 for content that changed, and users see stale pages no reload fixes. A wrong Vary is behind plenty of both.

How to debug it

Fetch once, capture the validator, then replay it:

etag=$(curl -sSI https://cdn.example.com/app.css | awk -F': ' '/^[Ee][Tt]ag/{print $2}' | tr -d '\r')
curl -sSI -H "If-None-Match: $etag" https://cdn.example.com/app.css | head -1

A correct server returns 304 on the second call. A 200 with an identical body means the validator is unstable — compare the ETag across several requests, and if it moves, check for multiple origins or an on-the-fly compressor.

If a 304 is served for content that did change, the problem is upstream of the client. Purge the CDN object, then verify with the header inspector that Vary lists every header the response depends on. Watch for 304s carrying Content-Length or a body: some clients hang waiting for bytes that never arrive.

Working examples

curl

# Capture the validator, then replay it as a conditional request.
etag=$(curl -sSI https://cdn.example.com/app.css \
  | awk -F': ' '/^[Ee][Tt]ag/{print $2}' | tr -d '\r')

curl -sSI -H "If-None-Match: $etag" https://cdn.example.com/app.css | head -1
# expect: HTTP/2 304

# Date-based validation, useful when the server has no ETag:
curl -sSI -H 'If-Modified-Since: Mon, 27 Jul 2026 00:00:00 GMT' \
  https://cdn.example.com/app.css | head -1

Python (requests)

import requests

first = requests.get("https://cdn.example.com/app.css", timeout=10)
etag = first.headers.get("ETag")
last_modified = first.headers.get("Last-Modified")

second = requests.get(
    "https://cdn.example.com/app.css",
    headers={"If-None-Match": etag} if etag
    else {"If-Modified-Since": last_modified},
    timeout=10,
)
print(second.status_code, len(second.content))  # 304 and 0 bytes

Node (fetch)

// A 304 has no body; resp.ok is false, which is not an error here.
const first = await fetch("https://cdn.example.com/app.css");
const etag = first.headers.get("etag");
await first.arrayBuffer();

const second = await fetch("https://cdn.example.com/app.css", {
  headers: etag ? { "If-None-Match": etag } : {},
  cache: "no-store", // stop the runtime from answering from its own cache
});

console.log(second.status, second.headers.get("etag"));
// 304 — treat it as success and reuse whatever you stored earlier.

Reference and tooling

The authoritative list of assigned status codes is theIANA HTTP Status Code Registry. For a searchable copy with the semantics summarised, use theHTTP status code reference on this site. To see exactly what a server is sending, theheader inspector shows the raw status line and response headers, and thecurl builder assembles the request flags for you.

Other 3xx codes