awesome-everything RU
↑ Back to the climb

Caching

ETags and conditional requests: 304 saves the bytes, not the round-trip

Crux The server fingerprints a resource into an ETag; the client revalidates with If-None-Match and gets a 304 with an empty body when nothing changed. The classic prod failure is an ETag that differs per node, so no client ever sees a 304.
Your altitude — climbing toward senior
ZeroJuniorMiddleSenior
You are at junior altitude — the surface
◷ 16 min

The dashboard polls /api/config every 30 seconds. You added ETags to “save bandwidth,” shipped it, and the egress bill didn’t move. You open DevTools: every poll is a clean 200 OK with the full 8 KB body. Not one 304. The config hasn’t changed in days. The bug isn’t in the client — you scaled the API to three pods last month, each computes its own ETag from a per-process counter, and the load balancer round-robins every request to a different pod. The client’s If-None-Match never matches whoever answers next.

The mechanism: fingerprint, echo, compare

A conditional request is a two-party agreement about a resource version. On the first response the server attaches an ETag header — an opaque token, usually quoted, that identifies this exact version of the body: ETag: "a3f5c901". The client stores it next to the cached copy.

On the next request the client asks “give me this only if it changed” by echoing the token in If-None-Match: "a3f5c901". The server recomputes (or looks up) the current ETag and compares:

  • Match → the client already has the current version. The server replies 304 Not Modified with no body and refreshed headers. The client serves its cached copy.
  • Mismatch → the version moved. The server replies 200 OK with the full new body and a new ETag.

The token is opaque on purpose: only the origin that minted it gets to interpret it. The client never parses it, never reasons about it — it just stores and echoes. That opacity is what lets you change your ETag scheme (hash → version number → mtime+size) without touching a single client.

StepDirectionHeader / status
1. First fetchclient → serverGET /api/config
2. Origin fingerprintsserver → client200 OK + ETag: “a3f5c901” + body
3. Revalidateclient → serverIf-None-Match: “a3f5c901”
4a. Unchangedserver → client304 Not Modified — empty body
4b. Changedserver → client200 OK + new ETag + new body

What 304 actually saves — and what it doesn’t

This is the number that decides whether ETags are worth it: a 304 saves the body bytes, but you still pay the full round-trip. The client must send the request and wait for the response before it knows the body is unchanged. If your RTT to origin is 80 ms, every poll still costs 80 ms of latency whether the answer is 200 or 304.

So the win is purely egress and download time. An 8 KB JSON config that returns 304 ships ~200 bytes of response headers instead of 8 KB — a 40× reduction on the wire, multiplied across every polling client. For a 2 MB product image that rarely changes, a 304 saves the entire 2 MB transfer on every revalidation. The bigger and more rarely-changing the resource, the better the trade.

But ETags are not free on the server. To answer a conditional request you must know the current ETag, which usually means reading or recomputing the resource — hashing the file, querying the row, rendering the response — just to compare and then throw the body away. That is the core CPU-vs-bandwidth tradeoff: you spend origin CPU to avoid network bytes. If the ETag is a cheap lookup (a stored version column, a file’s mtime+size from stat), the trade is obviously good. If it means SHA-256 hashing a 2 MB body on every request, you may burn more CPU than the bandwidth was worth.

Why this works

A 304 is not a cache hit in the CDN sense — it is a revalidation. The resource still travels the wire (just the headers) and still consumes a connection and a round-trip. This is why Cache-Control: max-age and ETags solve different problems: max-age lets the client skip the request entirely while fresh; the ETag is what it falls back to after freshness expires, to avoid re-downloading something it already has. They compose — fresh-by-max-age, then revalidate-by-ETag — they don’t replace each other.

Strong vs weak: byte-identical vs semantically equivalent

ETags come in two strengths, and the difference is a W/ prefix:

  • StrongETag: "a3f5c901" — promises byte-for-byte identity. Two strong ETags match only if the representations are exactly equal, every byte. Strong validators are required for range requests (If-Range): if you’re resuming a download at byte 1,000,000, “semantically equivalent” isn’t good enough — you need the exact same bytes.
  • WeakETag: W/"a3f5c901" — promises only semantic equivalence. A weakly-tagged page might differ in a timestamp comment or an ad slot but is “the same enough” to reuse. Use weak when computing a byte-exact tag is expensive or when trivial differences shouldn’t bust the cache.

The subtlety: If-None-Match always uses the weak comparison algorithm. For revalidation, W/"x" and "x" are treated as a match — the prefix is ignored. The strong/weak distinction only bites where byte-exactness is mandatory, which in practice means range requests. So for plain “did this change?” caching, picking weak vs strong rarely changes the outcome; for resumable downloads, it absolutely does.

ETag vs Last-Modified: the one-second blind spot

Before ETags there was Last-Modified / If-Modified-Since, a timestamp-based validator. It still works and is cheaper (just a stat on a file), but it has a hard limitation: HTTP dates have one-second resolution. If a resource changes twice within the same second — common for hot config, generated files, or high-write rows — Last-Modified can’t tell the two versions apart, and the client serves a stale 304. ETags don’t have that blind spot: a content hash or version counter changes on every real change regardless of timing.

The senior default: when both are present, ETags take precedence, and clients are expected to prefer If-None-Match over If-Modified-Since. Many servers emit both so dumb caches that only understand timestamps still get some revalidation, while ETag-aware clients get the precise one. The cost of emitting both is one extra header line.

ValidatorResolutionCost to computeUse when
ETag (content hash)Per-change, exactHigh — read/hash the bodyContent changes sub-second; precision matters
ETag (version/mtime+size)Per-change, cheap proxyLow — a lookup or statYou have a version column or stable file metadata
Last-ModifiedOne secondLow — a statStatic files that change rarely

Why it breaks in prod: the per-node ETag

The Hook’s bug is the canonical ETag failure, and it’s worth dwelling on because it’s invisible in single-server dev. The contract is simple: the same content must produce the same ETag, no matter which server answers. Break that and no client ever sees a 304.

Three classic ways to break it, all from the same root — the ETag depends on something local to one node instead of on the content:

  1. Inode-based ETags. Apache’s default FileETag historically included the file’s inode number. Identical files deployed to three servers have three different inodes, so three different ETags. Apache 2.4 dropped inode from the default precisely because it poisoned clustered caching; the fix is FileETag MTime Size (content-derived, identical everywhere).
  2. Timestamp/mtime drift across replicas. Even mtime-based ETags break if your deploy copies files at slightly different wall-clock times to each replica, or if a CI rebuild touches files. Same bytes, different mtime, different ETag.
  3. Per-process state. A counter, a random salt, or a startup timestamp baked into the ETag means each pod mints its own namespace — exactly the Hook’s failure.

And one more that surprises people: compression changes the ETag, or should. The gzipped and identity versions of a resource are different byte streams, so a strong ETag must differ between them — otherwise a client that sent Accept-Encoding: gzip and got a gzipped 200 could later revalidate, hit a node serving identity, and the byte-exact If-Range semantics break. Some proxies strip or weaken the ETag when they compress on the fly; if yours doesn’t, a compression layer added between deploys can silently invalidate every cached validator at once.

Pick the best fit

A JSON API behind 4 pods serves a 6 KB response polled every 15s by thousands of clients. The body changes a few times a day. You want revalidation to actually return 304s. Pick the ETag strategy.

Quiz

A client sends If-None-Match and the resource is unchanged. What does the server send, and what did it save?

Quiz

You scaled an API from 1 to 3 pods and revalidation stopped returning 304s. What's the most likely cause?

Order the steps

Order a successful conditional-GET exchange where the resource hasn't changed:

  1. 1 First request: client GETs the resource with no validator
  2. 2 Server replies 200 OK with the body and an ETag header
  3. 3 Client caches the body and stores the ETag next to it
  4. 4 Later: client re-requests with If-None-Match set to the stored ETag
  5. 5 Server recomputes the ETag, sees a match, replies 304 with an empty body
  6. 6 Client serves its cached copy — only headers crossed the wire
Recall before you leave
  1. 01
    A teammate says 'ETags make repeat requests free.' Correct them precisely: what does a 304 actually save, and what does it still cost?
  2. 02
    Why does adding a second and third server commonly kill 304s, and how do you make ETags load-balancer-safe?
Recap

A conditional request is a versioning agreement: the server fingerprints a resource into an opaque ETag, the client echoes it in If-None-Match, and a match returns 304 Not Modified with an empty body. That saves the body bytes — a 40× cut on a small JSON, the whole transfer on a large image — but never the round-trip, because the client must still ask and wait; ETags are a bandwidth optimisation, with the cost paid in origin CPU to recompute or read the resource just to compare. Strong ETags promise byte identity (required for range requests); weak ones (W/) promise only semantic equivalence, and If-None-Match compares weakly anyway, so the distinction mostly matters for resumable downloads. Against Last-Modified, ETags win on precision — timestamps have a one-second blind spot — at the cost of being more expensive to compute. The failure that bites in production is the per-node ETag: inode-based tags, mtime drift across replicas, per-process counters, or compression changing the bytes all make the same content hash differently on different servers, so behind a load balancer no client ever matches and you ship full 200s forever while believing caching is on. Keep the ETag a pure function of the content and every node agrees.

Continue the climb ↑ETags: multiple-choice review
shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.