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.staticStatus
(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 date304
If-None-Match: "zzz" + matching If-Modified-Since200
Matching If-None-Match + Cache-Control: no-cache200
HEAD with matching If-None-Match304

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:

CallServer sawJS saw
fetch #1200, no If-None-Match200
fetch #2304, If-None-Match sent200
fetch #3304, If-None-Match sent200
fetch(url, {cache:'no-store'})200, no If-None-Match200
fetch(url, {cache:'no-cache'})304200
fetch with a hand-written If-None-Match header304304, 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:

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.