304
Your copy is still good: you sent a validator with the request, the server compared it against the one it holds, they matched, and it deliberately sent headers and no body.
The same three digits are three different problems depending on which side of the request you are on. Read the block that describes you.
Why you are seeing it
The first thing to know is that a 304 usually does not reach your code. When the browser revalidates a cached response on its own, the HTTP cache consumes the 304 and hands your `fetch()` the stored response as a 200 — so `res.status === 304` in application code means *you* sent the conditional header, not the cache. The network panel does show the real 304 row, with a body of a few hundred bytes and a Size column crediting the cache. Two opposite complaints land here. One: you changed a file, reloaded, and got the old one anyway — which is not a broken 304 but the absence of one, because a response that is still fresh is reused with no request at all. Two: nothing ever revalidates, every reload re-downloads everything, and the cause is a missing or unstable validator on the server rather than anything the client did.
What to do
Use a hard reload (Cmd/Ctrl+Shift+R), which sends `Cache-Control: no-cache` and forces the question to reach the server, or tick "Disable cache" in DevTools to do it for every request while the panel is open. Then read the failing row in both directions: the request's `If-None-Match` / `If-Modified-Since` is what your browser remembered, and the response's `ETag` / `Last-Modified` is what it will send next time — if the second is missing, no amount of reloading will ever produce a 304. When you are measuring the server rather than the cache, request with `cache: "no-store"` so the browser cache cannot answer first. And settle the deployment shape once: HTML served `no-cache` so it is always revalidated, hashed assets served `immutable` so they never are, which removes both complaints at the same time.
Why you are seeing it
"We never get 304s" is the usual report, and the cause is almost always a validator that changes when the bytes do not. Across two or more nodes, a `Last-Modified` taken from the file's mtime differs per machine because each deploy wrote the file in a different second, so a client that revalidates against node B never matches what node A gave it — and Apache's `FileETag` default of `MTime Size` since 2.3.14 has the same property for entity tags. Compression is the other half: nginx has marked the entity tag weak (`W/"..."`) on gzipped responses since 1.7.3, which is still fine for `If-None-Match` because that comparison is weak, but disqualifies the response from `If-Range`. And if a CDN sits in front, ask whether it forwards `If-None-Match` to you at all — an origin that never receives the header can never answer 304, however correct its handler is.
What to do
Give the validator something that only changes with the content: hash the bytes, or use the build id, so every node serving the same artifact produces the same tag. Check `Vary` while you are there — `Vary: Accept-Encoding` makes the cache key on the negotiated encoding, so a client whose `Accept-Encoding` differs from the one that filled the entry gets a full 200 and looks like a cache failure. Confirm your handler really parses what arrives: `If-None-Match` may carry several comma-separated tags and any of them may be `W/`-prefixed, and a handler that string-compares the raw header against its own tag will miss all of them. When it matches, return 304 with the same `ETag` and no body at all — RFC 9110 terminates a 304 at the first blank line, so anything you write after the headers is read by the client as the beginning of the next response.
Why you are seeing it
304 is not an error and you are not supposed to notice it — it is the quiet half of a fast page load, where the browser asks "has this changed since I last fetched it?" and the site answers "no, use what you already have". The symptom that does surface is the opposite of an error: a page that keeps showing old content after the site has clearly been updated, because the copy your browser kept is still being reused.
What to do
Reload while holding Shift, or press Ctrl+Shift+R (Cmd+Shift+R on a Mac), which tells the browser to ask the server again rather than trust the copy it holds. If that fixes it once and the old version returns later, clear the cached files for that site in the browser's settings. A private window is the quickest diagnosis: it starts with an empty cache, so if the page is correct there and wrong in your normal window, the stale copy is on your machine and nothing is wrong with the site.
Run this against the URL that failed. It prints the status without the error page, so you can see what the server said rather than what the browser rendered.
curl -sS --etag-save /tmp/etag -o /dev/null https://example.com/style.css && curl -sS -D - -o /dev/null --etag-compare /tmp/etag https://example.com/style.cssThe first request saves whatever `ETag` the response carried into a file; the second sends it back as `If-None-Match`, which is precisely what a browser cache does on a revalidation, so this is a real 304 rather than a simulated one. `-D -` dumps the second response's headers, and the status line is the whole answer: `304` means the tag matched. Read four things off it. Was there an `ETag` in the first response at all — no tag and no `Last-Modified` means nothing on that URL can ever be revalidated, which explains a site that re-downloads itself on every visit. Does the 304 repeat the tag, as RFC 9110 requires? Does a `W/` prefix appear only once compression is negotiated — run the second command with and without `-H 'Accept-Encoding: gzip'`, and if one gets a 304 while the other gets a 200, the validator is per-encoding and `Vary: Accept-Encoding` is what the cache must key on. And which layer answered: `server:` and any `cf-ray` tell you whether the 304 came from an edge holding its own copy or from your origin. For the date-based path use `-z` instead, which curl documents as `--time-cond`: it builds an `If-Modified-Since` from a date you name or from a local file's timestamp, and a leading dash inverts it into `If-Unmodified-Since`.
RFC 9110 §15.4.5 defines 304 as the answer to a conditional GET or HEAD that would have returned 200 if the precondition had not evaluated to false. Two rules in that section matter while debugging. A 304 cannot carry content — it is terminated by the first empty line after the header fields — so middleware that writes a body onto one corrupts message framing rather than merely wasting bytes. And a 304 must repeat the header fields a 200 would have carried that the cache needs, which RFC 9110 names as `Content-Location`, `Date`, `ETag` and `Vary`, so a 304 with no `ETag` on it is already suspicious. The condition itself comes from §13: `If-None-Match` compares entity tags using the weak comparison function, `If-Modified-Since` compares an HTTP-date, and §13.2.2 sets the precedence — `If-Modified-Since` is only evaluated when `If-None-Match` is absent, which means a request carrying both is decided entirely by the tag and your `Last-Modified` never enters the argument. The two validators are not equally sharp: an HTTP-date has one-second resolution (§5.6.7), so two edits inside the same second are indistinguishable, whereas an entity tag is whatever the origin says it is and is strong unless it is prefixed with `W/`.
| Confused with | How to tell them apart |
|---|---|
200 | A 304 in the network panel and a 200 in your JavaScript are frequently the same exchange. The browser's HTTP cache issues the conditional request, receives the 304, and resolves your `fetch()` with the stored 200 — so the status your code observes is 200 even though the bytes never crossed the network. Seeing 304 in application code means the conditional header was yours. |
412 | The same failed precondition produces different codes depending on what you were trying to do. A validator that does not match on a GET is a 304, because you already hold a usable copy; on a state-changing method — `If-Match` on a PUT that lost the race — it is 412 (Precondition Failed, RFC 9110 §15.5.13), because there is nothing to reuse and the write must not proceed. |
no-cache | `no-cache` is what *creates* 304 traffic: it permits storage but requires revalidation before reuse, so every request asks and most answers are 304. `no-store` forbids keeping the response at all, so there is never a validator to send and never a 304 to receive. Reaching for `no-store` to fix a staleness bug throws away the cheap revalidation along with the stale copy. |
ETag | Strength is a real distinction and only one field cares. `If-None-Match` uses the weak comparison function, so `W/"v1"` and `"v1"` match and a gzip-weakened tag still yields 304s. `If-Range` and `If-Match` use strong comparison, so the same weakened tag silently disables range requests — which is why a large file re-downloads from zero after a dropped connection on a server that gzips. |
All of these run in your browser — nothing is uploaded.
Codes people usually end up reading in the same session as 304.
Read the response headers rather than the page. curl -sS -o /dev/null -D - https://example.com/path prints them without the body, and server, via and cf-ray between them name the layer that answered. A cf-ray value means Cloudflare handled the response and is the id their support will ask for. To take the edge out of the picture entirely, repeat the request with --resolve example.com:443:203.0.113.10, which connects to the origin address you name while still sending the original host name and SNI — if the answer changes, the edge and the origin disagree.
No, and you should never branch on it. RFC 9112 tells clients to ignore the reason phrase, servers are free to change it, and HTTP/2 and HTTP/3 do not carry one at all — so 404 Not Found over HTTP/1.1 arrives as a bare 404 over HTTP/2. The phrases on this site are the registered ones because they are what people search for and what appears in an HTTP/1.1 log, not because any software depends on them.
Retry 5xx and 429; do not retry 4xx, because nothing about the request will be different next time. If the response carries Retry-After, honour it — RFC 9110 defines it for exactly this, and it may be either a number of seconds or a date. Otherwise use exponential backoff with jitter and a hard cap, and only for idempotent methods: a retried POST can charge a card twice. A retry storm against a server that is already failing is how a brief incident becomes a long one.
Yes, and the only rule that matters is that the code has to be true. Anything that reads your responses automatically — search engines, monitoring, caches, client retry logic — makes decisions from the number and never from the page. Serving an error page with 200 hides the failure from your own alerting; serving 200 for a missing page gets the error indexed as content; returning 500 for a request that was simply malformed sends whoever is on call to the wrong half of the stack.
Still stuck on 304? browse every status code in the reference — or go back up to the block written for your side of the stack.