ETags and conditional requests: 304s, revalidation and lost update protection
Caching has two separate jobs. Cache-Control decides whether a client may skip asking. Validators decide what happens when it does ask. Most caching problems are really validator problems, and the validator is usually an ETag.
The basic exchange
The server labels a response:
HTTP/1.1 200 OK
ETag: "a9f2c1"
Cache-Control: max-age=60
Content-Type: application/json
Sixty seconds later the cached copy is stale, so the client asks whether the label still holds:
GET /api/orders/42 HTTP/1.1
If-None-Match: "a9f2c1"
If the resource is unchanged, the server answers:
HTTP/1.1 304 Not Modified
ETag: "a9f2c1"
Cache-Control: max-age=60
No body. The client reuses what it already has and resets the freshness window. If the resource did change, the server returns a normal 200 with the new body and a new ETag.
The saving is bandwidth, not server work. The server still has to determine the current ETag, which for a database backed resource usually means doing the query. A 304 on a 2 MB response is a large win; a 304 on a 400 byte JSON object mostly saves the round trip's payload and not much else.
Strong and weak validators
An ETag prefixed with W/ is weak:
ETag: W/"a9f2c1"
A strong ETag promises byte for byte identity. A weak one promises only semantic equivalence: the meaning is the same, but the bytes may differ. A page that embeds a rendering timestamp in a comment, or a JSON object serialised with different key order, is semantically the same and byte wise different.
The distinction matters for two things. Range requests require a strong validator, because resuming a download from byte 500000 is only safe if the first 500000 bytes are known to be identical. And If-Match, used for write concurrency below, requires a strong validator for the same reason.
If you cannot guarantee byte identity, mark the ETag weak. A strong ETag that is not actually strong produces corrupted resumed downloads, which is a far worse failure than a missed optimisation.
Last-Modified is the weaker alternative
Last-Modified: Wed, 03 Sep 2026 10:15:00 GMT
The client echoes it back in If-Modified-Since. It works, and it costs nothing to add when you already track a modification time, but it has two limits. HTTP dates have one second granularity, so two changes within the same second are indistinguishable. And it only answers "has it changed since", not "is it this exact version", so it cannot express a resource that reverted to an earlier state.
Send both when you have both. Clients that support ETags will prefer them; If-None-Match takes precedence over If-Modified-Since when a server receives both.
Generating an ETag
Three common strategies, with different costs:
| Strategy | Cost | Strength |
|---|---|---|
| Hash of the response body | Must build the body first | Strong |
Row version or updated_at plus ID | One cheap lookup | Weak, unless serialisation is stable |
| File mtime plus size plus inode | A stat call | Weak in principle, fine in practice |
Hashing the body is the safest and the most wasteful: you have done all the work of generating the response before discovering you did not need to send it. For an expensive endpoint, derive the ETag from a version column instead, so a 304 can short circuit before the expensive part runs.
Whatever you choose, the value must be quoted. ETag: a9f2c1 without quotes is malformed, and intermediaries handle it inconsistently. The hash generator is useful for checking that a value you are producing matches the digest you expect.
The compression trap
This is the most common ETag bug in production.
A reverse proxy that compresses a response must change the ETag, because the bytes changed. nginx historically dealt with this by deleting the ETag entirely when gzip was applied, so origin ETags simply vanished for compressed responses. Other proxies append a suffix such as -gzip. Others leave it untouched, which is the dangerous case: two different byte streams now carry the same strong validator, and a cache can serve gzip bytes to a client that asked for identity encoding.
Two consequences follow. Always send Vary: Accept-Encoding on compressible responses, so caches key the entry on the encoding. And check what your edge actually emits rather than what your application code sets, because the ETag on the wire is frequently not the one you wrote. Fetch the live URL with the HTTP header checker and compare it against the header your framework claims to be sending. A difference between the two tells you something in the path is rewriting it.
The same caution applies behind a CDN. If the CDN generates its own ETags, your application's ETag never reaches the client, and a deploy that changes your version scheme changes nothing user visible.
If-Match: the other half
The same validators solve a different problem: two clients editing the same resource, where the second write silently overwrites the first.
PUT /api/orders/42 HTTP/1.1
If-Match: "a9f2c1"
Content-Type: application/json
The server compares the supplied ETag against the current one. If they match, the write proceeds. If they do not, the resource changed since the client read it, and the server refuses:
HTTP/1.1 412 Precondition Failed
The client then re-reads, merges or asks the user, and retries. This is optimistic concurrency control expressed in HTTP rather than in a bespoke version field, and it is a much better answer than last write wins for anything a person edits.
A related form is If-None-Match: * on a POST or PUT, which means "only create this if it does not already exist". The server returns 412 if it does. That gives you a conditional create without a separate existence check and its associated race.
Note the asymmetry in status codes: a failed If-None-Match on a read is a 304, a failed If-Match on a write is a 412. HTTP status codes explained covers where each belongs.
Practical checklist
- Send an ETag on every cacheable GET, quoted, and weak unless you can guarantee byte identity.
- Send
Vary: Accept-Encodingwherever the response can be compressed. - Handle
If-None-Matchon the server, and return the ETag andCache-Controlagain on the 304. - Compare the list of ETags in
If-None-Matchproperly. It is a comma separated list, and*matches anything. - Accept
If-Matchon PUT, PATCH and DELETE for resources that more than one client can write. - Verify against the deployed URL, not the local one, because the proxy layer is where validators get rewritten.
Related reading: Cache-Control headers explained covers the freshness half of the system, HTTP headers explained is the wider reference, and what is a reverse proxy covers the layer that most often rewrites these values.