Skip to content
Control Plane Labs

304 Not Modified

Redirection response defined by RFC 9110 §15.4.5.

Written and maintained by Ben Ennis

Last reviewed July 27, 2026 · How we verify this

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.

Why a 304 can never have a body, no matter what headers say

RFC 9112 §6.3 puts 304 in the same bucket as 1xx, 204, and any response to HEAD: it is always terminated by the first empty line after the header fields, regardless of the header fields present in the message. That “regardless” is doing real work. Unlike every other framing rule in that section, this one is not conditional on Content-Length or Transfer-Encoding — a 304 has no body even if those headers claim otherwise, and a compliant parser must stop reading at the blank line and treat anything after it as the start of the next response on the connection.

The failure mode is a proxy or framework that copies headers from the 200 it would have sent and forgets to strip Content-Length. RFC 7232 §4.1 permits a 304 to carry a Content-Length matching what the 200 would have been, precisely so caches can update their stored size, but it must not be followed by that many bytes of body. A strictly conformant HTTP/1.1 client ignores the mismatch and moves on. A naive one, especially anything built on raw sockets rather than a proper HTTP parser, reads until it has consumed Content-Length bytes, consumes part of the next response’s headers by mistake, and the connection desyncs in a way that looks like a hung request rather than a spec violation.

This is why 304s are one of the few response types worth checking with a raw openssl s_client or packet capture rather than a well-behaved HTTP library when a keep-alive connection seems to stall after a cache hit: the library will already be hiding the malformed framing from you.

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