304 Not Modified (tested)
304 Not Modified means "your cached copy is still good, so here are no bytes". The server only sends it when the request carries a validator: If-None-Match (an ETag) or If-Modified-Since (a date). It is normal and it saves bandwidth. We tested it with curl, Node fetch and headless Chromium.
1. The basic round trip
We served a 19-byte file with express.static (Express 5.2.1). The first request had no conditional headers and got a 200 with two validators:
$ curl -sv http://127.0.0.1:8983/index.html
< HTTP/1.1 200 OK
< Cache-Control: public, max-age=0
< Last-Modified: Fri, 02 Oct 2026 02:45:13 GMT
< ETag: W/"13-1a0fa80667e"
< Content-Length: 19
Then we sent the ETag back in If-None-Match:
$ curl -s -D - -o /dev/null -w 'size=%{size_download}\n' \
-H 'If-None-Match: W/"13-1a0fa80667e"' http://127.0.0.1:8983/index.html
HTTP/1.1 304 Not Modified
Accept-Ranges: bytes
Cache-Control: public, max-age=0
Last-Modified: Fri, 02 Oct 2026 02:45:13 GMT
ETag: W/"13-1a0fa80667e"
size=0
The 304 had a body of 0 bytes. It kept ETag, Last-Modified and Cache-Control. It dropped Content-Type and Content-Length. A request with no conditional headers always got 200 in our run. Express never sent 304 unprompted.
More on the two validators: ETag, Last-Modified and If-Modified-Since.
2. Which conditional requests got a 304
| Request headers sent to express.static | Status |
|---|---|
| (none) | 200 |
If-None-Match: W/"13-1a0fa80667e" (exact) | 304 |
If-None-Match: "13-1a0fa80667e" (W/ prefix removed) | 304 |
If-None-Match: W/"other" | 200 |
If-None-Match: * | 304 |
If-Modified-Since = the Last-Modified date | 304 |
If-None-Match: "zzz" + matching If-Modified-Since | 200 |
Matching If-None-Match + Cache-Control: no-cache | 200 |
HEAD with matching If-None-Match | 304 |
Three things stand out. Express uses weak comparison, so a CDN or proxy that adds or strips the W/ prefix (common after gzip) did not break 304s here. When If-None-Match is present and does not match, a matching If-Modified-Since is ignored. And a request Cache-Control: no-cache makes Express send a full 200 even when the ETag matches. Dynamic routes behave the same: res.send() set ETag: W/"1e-rsN2..." and returned 304 when we sent it back.
Other servers may compare ETags strictly. That is from the docs, not measured: RFC 9110 says If-None-Match uses weak comparison, but some servers and CDNs get this wrong.
3. What JavaScript sees in the browser
We loaded a page in headless Chromium and called fetch('/index.html') three times. The server logged each hit:
| Call | Server saw | JS saw |
|---|---|---|
| fetch #1 | 200, no If-None-Match | 200 |
| fetch #2 | 304, If-None-Match sent | 200 |
| fetch #3 | 304, If-None-Match sent | 200 |
fetch(url, {cache:'no-store'}) | 200, no If-None-Match | 200 |
fetch(url, {cache:'no-cache'}) | 304 | 200 |
| fetch with a hand-written If-None-Match header | 304 | 304, empty body |
The browser cache adds If-None-Match by itself. When the server answers 304, the browser serves the cached body and JS sees 200. Every call still reached the server, because max-age=0 means "revalidate every time". JS only saw a raw 304 when we added the conditional header ourselves. Then the body was empty.
4. Node fetch has no cache
Three plain fetch() calls in Node.js 24 all reached the server with no If-None-Match, and all got 200. Node fetch never revalidates on its own.
Adding If-None-Match by hand in Node had a surprise: we still got 200. We logged the raw request and found that Node fetch also sent Cache-Control: no-cache and Pragma: no-cache. Express treats that as "send me a fresh copy".
// What Node 24 fetch actually sent with {headers:{'If-None-Match':'x'}}
"if-none-match":"x", "pragma":"no-cache", "cache-control":"no-cache"
Setting 'cache-control': 'max-age=0' yourself overrides it. With that, Node fetch got the 304.
5. Why you see 304 in DevTools and logs
A 304 in the Network panel or access log is normal. It means the browser had a copy and asked "has it changed?". The answer was no, so only headers came back. It is not an error.
To force a full 200:
- curl: send no conditional headers. Plain
curl URLgot 200 every time. - Browser fetch: use
{cache: 'no-store'}. It sent no validator and got 200 from the server. - Any client: add
Cache-Control: no-cacheto the request. Express then returned 200 even with a matching ETag. - DevTools: tick "Disable cache" (from the docs, not measured).
To stop the browser from storing the response in the first place, the server sends Cache-Control: no-store. See no-cache vs no-store.
Frequently asked questions
Is a 304 Not Modified an error?
No. It means the client's cached copy is still current, so the server sent headers only. In our test the 304 body was 0 bytes and the browser served its cached copy.
Why does my fetch() show 200 when the server logged 304?
The browser cache sent If-None-Match, got the 304, and handed JS the cached body as a 200. In headless Chromium, JS saw a raw 304 only when we added If-None-Match ourselves.
Which headers does a 304 keep?
From express.static we got ETag, Last-Modified, Cache-Control and Accept-Ranges. Content-Type and Content-Length were not sent.
Does a W/ weak ETag prefix break 304s?
Not in Express 5.2.1. Sending the ETag with or without W/ both got 304. Other servers or CDNs may compare strictly, so check yours.
Why does Node fetch get 200 even with If-None-Match?
Node 24 fetch added Cache-Control: no-cache and Pragma: no-cache to the request, and Express answered with a full 200. Setting cache-control: max-age=0 yourself gave a 304.
Written with AI assistance. Measured on 2026-10-02 with curl 8.14.1, Node.js 24 (built-in fetch), Express 5.2.1 (express.static and res.send) on 127.0.0.1:8983, and headless Chromium driven by Playwright 1.63.0. The server logged every request's status and If-None-Match header. PlainHeaders is our extension; curl, Node.js, Express and Chromium are free.