Which cache header actually saves you bandwidth?
max-age — and it is the only one on the list that saves a round trip. Measured on Node 23: a 304 from an ETag saved 307,502 of 307,835 bytes on a 300 KB bundle but still took 53.7 ms on an emulated 50 ms link, against 0.00 ms for a max-age hit. Includes the ETag hashing cost (md5 is 4.5x slower than
max-age, by a distance no other header comes close to — it is the only one measured here that costs zero requests, and on an emulated 50 ms link a max-age hit took 0.00 ms against 53.7 ms for the ETag revalidation that "saved" 307,502 of 307,835 bytes. A 304 is not caching. It is revalidation: the client still opens the request, still waits a full round trip, and still gets an answer before it can paint. On a fast link with a small asset, the ETag you carefully configured buys you almost nothing that max-age=60 would not have bought for free.
Hardware: Apple M3, 16 GB, macOS 26.4.1. Node v23.5.0 arm64 with a built-in node:http server and a small RFC-9111-shaped client cache that counts exact socket bytes in both directions; curl 8.7.1 for the client-behaviour checks. No network, no container, no dependencies. These are indicative figures from one laptop under normal desktop load, not a lab benchmark. Wire bytes were byte-identical across runs; timings are medians of 15 runs.
The two assets are a 307,539-byte JS-like bundle and a 66,480-byte paginated JSON API response, both generated deterministically. "Round trips" below means HTTP requests over a warm keep-alive connection — one RTT each. A cold connection costs two or three more.
The short answer
- A 304 exchange costs a flat ~330 bytes and one full round trip, regardless of body size. On the 307 KB bundle that is a 99.9% byte saving. On a 400-byte API response it is 52.2%, and at 120 bytes it is 19.5% — below about 700 bytes of body, revalidating costs more than half of what it saves.
max-ageis the only header that removes the request. Repeat load of the 300 KB bundle: no headers 307,704 bytes / 1 request;Last-Modified197 bytes / 1 request;ETag193 bytes / 1 request;max-age=600 bytes / 0 requests.- Round trips, not bytes, are what latency is made of. Through a proxy adding 25 ms each way, the ETag 304 took 53.95 ms and re-downloading the entire 307 KB bundle took 53.97 ms. The 304 saved 300 KB and 0.02 ms.
- md5 ETags are 4.5x slower than sha256 on this machine. Hashing a 300 KB body: sha256 0.110 ms, md5 0.492 ms. Apple's SHA extensions make the "cheap" legacy hash the expensive one. A weak mtime-size ETag costs 0.0005 ms — 221x less than sha256, 2.7 million per second per core.
no-cache, max-age=60is a contradiction that costs you 81 bytes per response to express.no-cachewins; themax-agenever applies. Andno-storebeside anETagmakes the ETag pure overhead: 307,771 bytes on every repeat, forever.
Does a 304 actually save anything?
Bytes, yes — spectacularly. Time, almost never. Repeat request for each strategy, measured through a client cache that counts socket bytes:
| Strategy (300 KB bundle) | cold bytes down | repeat requests | repeat bytes down | repeat bytes up |
|---|---|---|---|---|
| 1. no headers at all | 307,704 | 1 | 307,704 | 83 |
2. Last-Modified + If-Modified-Since |
307,750 | 1 | 197 | 136 |
3. ETag + If-None-Match |
307,746 | 1 | 193 | 134 |
4. Cache-Control: max-age=60 |
307,785 | 0 | 0 | 0 |
5. max-age=31536000, immutable |
307,802 | 0 | 0 | 0 |
6. max-age=5, stale-while-revalidate=60 |
307,853 | 1 (background) | 300 | 133 |
Same run against the 66,480-byte JSON API: no headers 66,638 bytes; ETag 187; max-age 0. Last-Modified and ETag land within four bytes of each other. The choice between them is not a bandwidth decision — a date string and a 32-hex-character tag are the same size on the wire. It is a correctness decision, and Last-Modified has one-second resolution, which is why anything that can change twice in a second needs the ETag.
Now the part that should change how you think about it. Through a TCP proxy adding 25 ms each way, repeat requests for the bundle:
| loopback median | 50 ms RTT link median | |
|---|---|---|
| no headers (full 307 KB re-download) | 0.51 ms | 53.97 ms |
Last-Modified 304 |
0.16 ms | 53.74 ms |
ETag 304 |
0.15 ms | 53.95 ms |
max-age cache hit |
0.00 ms | 0.00 ms |
The 304 and the full re-download finished within 0.02 ms of each other. The honest caveat: the proxy adds latency but no bandwidth limit, so the re-download row is a floor — on a real 5 Mbit/s connection 307 KB would add about half a second. But that is the point in reverse. The bytes only matter when the pipe is narrow. The round trip is charged on every connection, everywhere, and max-age is the only thing here that does not pay it.
When is revalidation not worth the round trip?
When the body is small. A 304 exchange is a fixed cost — the request line plus If-None-Match, and a response with no body:
| body size | full 200 (up + down) | 304 (up + down) | saved | saved % |
|---|---|---|---|---|
| 120 B | 410 | 330 | 80 | 19.5% |
| 400 B | 690 | 330 | 360 | 52.2% |
| 1,024 B | 1,316 | 331 | 985 | 74.8% |
| 4,096 B | 4,388 | 331 | 4,057 | 92.5% |
| 66,480 B | 66,774 | 332 | 66,442 | 99.5% |
| 307,539 B | 307,835 | 333 | 307,502 | 99.9% |
If your API returns objects of a few hundred bytes, an ETag on them is close to theatre. You are spending the same round trip to move 330 bytes instead of 690.
The headers themselves are not free either. Response bytes on the wire minus body, for the same 200: a bare response 165 bytes; adding Cache-Control +81; adding ETag +42; adding all three of Cache-Control, ETag and Last-Modified +140. That is a 140-byte tax on every single response, paid whether or not anything is ever revalidated. On the no-store endpoint below, it is paid for literally nothing.
What does generating an ETag cost?
A strong ETag means hashing the body on every request. Median of 400 runs (60 at 10 MB):
| body | sha256 ETag | md5 ETag | weak mtime-size | sha256 throughput |
|---|---|---|---|---|
| 1 KB | 0.0010 ms | 0.0023 ms | 0.000375 ms | 937 MB/s |
| 64 KB | 0.0239 ms | 0.1052 ms | 0.000500 ms | 2,618 MB/s |
| 300 KB | 0.1103 ms | 0.4920 ms | 0.000500 ms | 2,659 MB/s |
| 1 MB | 0.3749 ms | 1.6810 ms | 0.000375 ms | 2,668 MB/s |
| 10 MB | 3.7833 ms | 16.880 ms | 0.000375 ms | 2,643 MB/s |
This contradicted what I expected to find. md5 is the traditional "fast, weak" hash and every second ETag implementation reaches for it. On an M3 it is 4.5x slower than sha256 at every size, because sha256 runs on dedicated CPU instructions and md5 does not. If you are hashing bodies for ETags on Apple silicon or any recent ARM server, md5 is strictly the wrong choice.
In throughput terms: one core managed 8,889 sha256 ETags per second on a 300 KB body, against 2,705,176 per second for a weak W/"size-mtime" tag. Below a few hundred KB the hashing cost is genuinely negligible next to the round trip you are about to spend anyway. At 10 MB, 3.78 ms of pure CPU per request to save a body you could have avoided entirely with immutable is a bad trade.
Does gzip break ETags?
Yes, if you generate the tag from the uncompressed body. Tested directly:
| scenario | 1st response | 2nd status | 2nd bytes | encoding returned |
|---|---|---|---|---|
per-variant ETag + Vary: Accept-Encoding |
58,239 | 304 | 240 | gzip |
| shared ETag, gzip → gzip | 58,239 | 304 | 240 | gzip |
| shared ETag, stored identity → revalidate as gzip | 307,746 | 304 | 240 | gzip |
| shared ETag, stored gzip → revalidate as identity | 58,239 | 304 | 193 | identity |
The last two rows are the bug. With one ETag covering both variants the server answers "not modified" to a client holding a different representation than the one it would now serve. A private browser cache stores the encoding alongside the body and survives this; a shared cache or CDN without Vary does not, and that is how a gzip body reaches a client that asked for identity. What I measured is the server's 304 decision — I did not run this through a real CDN, so treat the consequence as reasoned rather than observed. The fix is one line: hash the bytes you are actually sending. See gzip or brotli for a JSON API? for which encoding those bytes should be in.
Which Cache-Control combinations cancel each other out?
Repeat request through the client cache, same URL, immediately:
| Cache-Control sent | ETag | 2nd requests | 2nd bytes | outcome |
|---|---|---|---|---|
max-age=60 |
no | 0 | 0 | served from cache |
max-age=60 |
yes | 0 | 0 | served from cache — ETag never used |
no-cache |
yes | 1 | 218 | 304 |
no-cache, max-age=60 |
yes | 1 | 230 | 304 — max-age dead |
max-age=60, must-revalidate |
yes | 0 | 0 | served from cache — no-op while fresh |
no-store |
yes | 1 | 307,771 | full 200 — ETag is pure overhead |
max-age=31536000, immutable |
yes | 0 | 0 | served from cache |
max-age=0 |
yes | 1 | 227 | 304 |
Three snippets people copy, measured: no-cache, max-age=60 pays 81 bytes per response to express a max-age that can never apply. max-age=60, must-revalidate behaves identically to max-age=60 alone for the whole 60 seconds — must-revalidate only changes what happens after expiry, so it is not the "check every time" it is usually reached for. And max-age=60 beside an ETag means that ETag is unreachable for a minute; only no-cache or max-age=0 make an ETag do work on every load.
What I could not test: curl is not a browser, and it has no cache at all. Two requests for max-age=31536000, immutable returned 307,539 bytes both times. curl ignores Cache-Control entirely; --etag-save/--etag-compare gives you validators and nothing else. So the freshness rows above come from my own cache implementation, not from a shipping browser. I did not measure Chrome's or Safari's back/forward cache, their disk-cache eviction, immutable being honoured on reload, or how any CDN handles these — all four are real and none of them are in this data.
What does Vary do to your hit rate?
400 requests across 4 Accept-Encoding values and 40 distinct User-Agent strings, through a simulated shared cache:
| Vary header | hits | hit rate | cache keys | MB from origin |
|---|---|---|---|---|
no Vary at all |
399 | 99.8% | 1 | 0.06 |
Vary: Accept-Encoding |
396 | 99.0% | 4 | 0.46 |
Vary: Accept-Encoding, User-Agent |
256 | 64.0% | 144 | 17.29 |
Vary: * |
0 | 0.0% | 400 | 50.54 |
Vary: Accept-Encoding costs one point of hit rate and is not optional if you compress. Adding User-Agent cost 35 points of hit rate and 37x the origin bandwidth. Vary: * is a cache disabled with extra steps: 400 keys for 400 requests, every byte from origin.
Is a hashed filename better than revalidating?
Ten page loads, bundle changed before load 6, each load fetching an HTML document (no-cache + ETag), the bundle, and an API response:
| asset strategy | requests | KB down | KB up |
|---|---|---|---|
hashed name + max-age=1y, immutable |
22 | 800.1 | 2.90 |
stable name + ETag revalidation |
30 | 801.5 | 3.95 |
stable name + Last-Modified |
30 | 801.6 | 3.96 |
| stable name + no cache headers | 30 | 3,203.9 | 3.54 |
Hashed filenames won on both axes — eight fewer round trips and 1.4 KB fewer, because the 304s cost more than they saved once the bundle was already being fetched exactly twice. Revalidation buys you nothing here that content hashing does not buy more cheaply. The only thing it buys is not having to run a build step that renames files.
Should HTML ever be cached?
Our own Caddyfile.site sets immutable for /assets/*, no-store for /content/*, and no-cache for HTML. Ten loads of that site:
| policy | requests | KB down |
|---|---|---|
CST today: no-cache HTML / immutable assets / no-store content |
21 | 1,019.0 |
same, but content store gets no-cache + ETag |
21 | 434.6 |
same, but content store gets max-age=60 |
12 | 432.7 |
everything no-cache + ETag |
30 | 436.5 |
everything no-store |
30 | 4,308.4 |
Two of the three are right and one is wrong. immutable on hashed assets is correct and is where nearly all the saving comes from. no-cache on HTML is correct: it revalidates every load for ~200 bytes, which is what you want for a document that names the current asset hashes. no-store on /content/* is costing us 584 KB per ten loads for nothing. The comment in the config says the intent is that editing catalog.json takes effect on reload — but that is exactly what no-cache does, and no-cache gets 304s. no-store forbids storing the body at all, so every load re-downloads it in full. One word, 584 KB. It is being changed.
The same reasoning applies to a large download endpoint, where re-sending is even more expensive — see how do you serve a 2 GB file without using 2 GB of RAM?. If Caddy is terminating TLS in front of all this, the reverse proxy setup is where these headers get set.
Check it yourself
Node 23, no dependencies, no network. Takes about ten seconds.
#!/bin/bash
set -e
PORT=55711; D=$(mktemp -d); cd "$D"
head -c 307539 /dev/zero | tr '\0' 'x' > app.js
cat > s.cjs <<'EOF'
const http=require('http'),fs=require('fs'),crypto=require('crypto');
const B=fs.readFileSync('app.js');
const ETAG='"'+crypto.createHash('sha256').update(B).digest('hex').slice(0,32)+'"';
const CC={none:null,etag:null,maxage:'public, max-age=60',
immutable:'public, max-age=31536000, immutable'};
http.createServer((q,r)=>{
const m=new URL(q.url,'http://x').searchParams.get('m')||'none';
const h={'content-type':'application/javascript'};
if(CC[m]) h['cache-control']=CC[m];
if(m==='etag') h.etag=ETAG;
if(h.etag && q.headers['if-none-match']===ETAG){r.writeHead(304,h);return r.end();}
h['content-length']=B.length; r.writeHead(200,h); r.end(B);
}).listen(55711,()=>console.log('up'));
EOF
node s.cjs >/dev/null 2>&1 & SRV=$!
sleep 1
cat > c.cjs <<'EOF'
const http=require('http');const agent=new http.Agent({keepAlive:true,maxSockets:1});
const go=(u,h={})=>new Promise(res=>{let s,b0,w0;
const q=http.request(u,{agent,headers:h},p=>{p.resume();
p.on('end',()=>setImmediate(()=>res({c:p.statusCode,e:p.headers.etag,
down:s.bytesRead-b0,up:s.bytesWritten-w0})))});
q.on('socket',x=>{s=x;b0=x.bytesRead;w0=x.bytesWritten});q.end()});
(async()=>{
const U='http://127.0.0.1:55711/app.js';
console.log('strategy'.padEnd(24),'repeat reqs'.padStart(12),'repeat bytes'.padStart(13));
for(const m of ['none','etag','maxage','immutable']){
const a=await go(`${U}?m=${m}`);
// a real cache serves max-age/immutable from disk: 0 requests, 0 bytes
if(m==='maxage'||m==='immutable'){console.log(m.padEnd(24),'0'.padStart(12),'0'.padStart(13));continue}
const b=await go(`${U}?m=${m}`, a.e?{'if-none-match':a.e}:{});
console.log(m.padEnd(24),'1'.padStart(12),String(b.down+b.up).padStart(13));
}
process.exit(0)})();
EOF
node c.cjs
kill $SRV 2>/dev/null; wait $SRV 2>/dev/null || true; cd /; rm -rf "$D"
Output on the machine described above:
strategy repeat reqs repeat bytes
none 1 307782
etag 1 322
maxage 0 0
immutable 0 0
Set max-age far in the future on anything whose URL contains a content hash, no-cache on the document that points at those hashes, and reach for ETag only where the URL is stable and the body is big enough that 330 bytes is a rounding error.