A conditional request is a normal GET with a question attached: "send the body only if it changed." The client sends the validator it stored (If-None-Match: "abc" or If-Modified-Since: <date>); if it still matches, the server replies 304 Not Modified with no body, and the client reuses its cached copy. You save the transfer, not the round trip.
The Cache-Control post covered when a cache may skip the network entirely. This one covers what happens after max-age runs out, or when you send no-cache: the revalidation request. It's where most "why is my 304 a 200?" bugs live.
The whole exchange, on the wire
First request. The server attaches a validator:
GET /app.css HTTP/1.1
Host: example.com
HTTP/1.1 200 OK
Cache-Control: max-age=60
ETag: "33a64df5"
Last-Modified: Tue, 06 Oct 2026 09:12:00 GMT
Content-Length: 48211Sixty seconds later the copy is stale, so the browser asks instead of downloading:
GET /app.css HTTP/1.1
Host: example.com
If-None-Match: "33a64df5"
If-Modified-Since: Tue, 06 Oct 2026 09:12:00 GMT
HTTP/1.1 304 Not Modified
ETag: "33a64df5"
Cache-Control: max-age=6048 KB became a few hundred bytes of headers. Note the 304 also carries fresh Cache-Control, which resets the freshness clock on the stored copy. That's why a 304 is useful even when the body never changes.
ETag vs Last-Modified
ETag | Last-Modified | |
|---|---|---|
| Resolution | Opaque string, any granularity | One second |
| Request header | If-None-Match | If-Modified-Since |
| Breaks when | Generated differently per server | Content changes twice within a second, or file mtime changes with identical bytes (a redeploy) |
| Wins if both sent | Yes — RFC 9110 says If-Modified-Since is ignored when If-None-Match is present | No |
The redeploy case is the common one. Docker builds and
git checkoutreset every file's mtime, soLast-Modifiedchanges while the bytes don't, and every client re-downloads files that are identical. Send an ETag derived from content and this stops.
Strong vs weak ETags
"33a64df5" is strong: byte-for-byte identical. W/"33a64df5" is weak: semantically equivalent, bytes may differ. For If-None-Match revalidation, caches use weak comparison, so either form works. Strong matters for range requests and If-Match, which need exact bytes.
The practical gotcha: compression. Many servers and CDNs weaken the ETag (nginx prepends W/) or strip it when gzip or brotli is applied on the fly, because the compressed bytes differ per encoding. If your 304s vanish only for compressed assets, check the ETag in the response, not the code.
Why your 304 never fires
- Per-server ETags. Apache's default ETag historically mixed in the inode; two servers behind a load balancer produced different ETags for the same file, so every other request missed. Derive the tag from content (a hash), never from filesystem metadata.
- Quote marks. The value includes the double quotes.
ETag: 33a64df5without them is invalid, and a client may never send it back. no-store. The browser doesn't keep the body, so there's nothing to revalidate. You get a plain 200 every time.- A CDN that rewrites the response. Image optimizers and minifiers change the body but may forward the origin ETag, or drop it. Compare headers at the origin and at the edge.
- Dynamic responses with no validator. An API handler that never computes an ETag can't 304. Hashing the serialized body is cheap compared with sending it.
Generating an ETag for a dynamic response
The server still builds the response; the saving is bandwidth, not CPU. That's usually the right trade for large JSON.
import { createHash } from "node:crypto";
export function respond(req: Request, body: string): Response {
const etag = `"${createHash("sha1").update(body).digest("base64url").slice(0, 16)}"`;
const headers = { ETag: etag, "Cache-Control": "no-cache" };
const sent = req.headers.get("if-none-match");
if (sent && sent.split(",").some((t) => t.trim().replace(/^W\//, "") === etag)) {
return new Response(null, { status: 304, headers });
}
return new Response(body, { headers });
}Two details people miss: If-None-Match can hold a comma-separated list (or *), and the 304 must repeat the ETag header. Cache-Control: no-cache here is deliberate. It means "store it, but revalidate every time," which is exactly the contract an ETag serves. If you're composing the header, the Cache-Control header builder helps you check the directive combination.
Don't ETag what you can fingerprint
For hashed build assets (app.3f9a1c.js), skip revalidation entirely: Cache-Control: public, max-age=31536000, immutable. A 304 is still a round trip; immutable is none. ETags earn their keep on URLs that can't change name: HTML documents, API responses, unversioned images.
FAQ
Does a 304 cost anything?
One round trip and a few hundred bytes of headers. On high-latency mobile links that round trip is the real cost, which is why long max-age plus fingerprinted names beats revalidation.
Can I use ETag for optimistic locking?
Yes, with If-Match on PUT or PATCH: the server returns 412 Precondition Failed if the resource changed. It requires strong ETags.
Why does a hard refresh skip my 304?
Reload sends Cache-Control: max-age=0 on the request, and a hard reload bypasses the cache outright. DevTools "Disable cache" does the same, so test 304s with a normal navigation.
The short version: put a content-derived ETag on anything that can't be fingerprinted, make sure it survives compression and your CDN, and let no-cache do the rest.
