Article
ETags and If-Match for Safe HTTP Updates
API versioning protects the JSON shape. ETags and If-Match protect the row. How to stop two clients from silently overwriting each other.
Two clients can read the same JSON document, edit it, and write back. Without a version token, the second write wins and the first edit disappears. HTTP already has that contract: an ETag on the response, and If-Match or If-None-Match on the next request. This is the instance version, not the API version. Additive fields can wait for a new /v2. A lost excerpt cannot.
What an ETag is
An ETag is an opaque string the server associates with one representation of a resource. Strong ETags change whenever the bytes change. Weak ETags start with W/ and can stay the same across equivalent representations, which is useful for gzip but a poor fit for “did anyone else edit this row.”
A GET that can be cached should send ETag and, when you have it, Last-Modified. A client that already has a copy sends If-None-Match with that ETag. If the resource is unchanged, the server answers 304 with an empty body. That is a cache hit. It is not a lost-update check.
Lost updates are a different verb
Caching uses If-None-Match on GET. Safe updates use If-Match on PUT or PATCH. The client sends the ETag it last saw. If the current resource no longer matches, the server answers 412 Precondition Failed and does not apply the body. The client must GET again, merge, and retry with the new ETag.
If you accept PUT without If-Match, last-write-wins is the contract, whether you documented it or not. Mobile apps, double-clicks, and retries will hit that path. Spell it in the API doc next to the JSON shape, the same way you spell required fields in the versioning article.
A small example
GET /api/v1/posts/geo-distance
ETag: "8f14e45f"
PUT /api/v1/posts/geo-distance
If-Match: "8f14e45f"
Content-Type: application/json
{"excerpt":"Updated excerpt"}
If another writer already stored a new excerpt, that PUT must not overwrite it. Return 412, include the current ETag if you can, and keep the stored body. Nest and most HTTP stacks will pass If-Match through. Your service still has to compare it to the stored version, not only to a CDN cache key.
What to store
Store a version per resource: a monotonically increasing integer, a row updatedAt in UTC, or a hash of the canonical JSON. Send it as the ETag. Do not derive the ETag only from a CDN layer if the origin can change under it. Do not reuse an ETag for a new meaning after a migration—the same rule as never reusing a JSON field.
If-Match: * means “write if the resource exists.” If-None-Match: * on PUT means “write only if it does not exist,” which is how some APIs implement create-once. Pick one of those and document it. Mixing them without a test will produce duplicate posts or silent overwrites.
Retries and idempotency
An ETag stops lost updates. It does not make a POST that charges a card safe to retry. For that you still need an idempotency key on the write that creates a side effect. Use both: If-Match when the client is editing a known resource, an Idempotency-Key header when the client is creating an effect it might submit twice.
Version the representation when the JSON shape changes. That is API versioning. Version the instance when this row changed. That is this article. Confusing the two is how a v2 client still clobbers a v1 edit.
What to ship this week
- Send
ETagon GET for resources that can be edited. - Require
If-Matchon PUT and PATCH for those resources, or document last-write-wins explicitly so clients stop guessing. - Map a mismatch to 412, not 400 or 409, unless you already published a different conflict shape and cannot change it.
- Log 412 rates. A spike is two writers, a stale mobile build, or a CDN serving the wrong ETag.
- Add one integration test: two PUTs with the same ETag, second body different. The second must 412 and leave the first body in place.
Conditional requests are the contract that keeps two clients from silently erasing each other. Put them next to the version number in the API doc, and treat a missing If-Match the same way you treat a missing Authorization header: reject it, or admit that last-write-wins is what you ship.