To stop two clients overwriting each other, make the server hand out an ETag on every GET, make the client echo it back as If-Match on PUT or PATCH, and have the server reply 412 Precondition Failed when the stored version no longer matches. Reject writes that carry no If-Match at all with 428 Precondition Required. That is optimistic concurrency control, and it is built into HTTP: no lock, no version field in your URL scheme.
This is the write-side sibling of ETag and 304. Same header family, opposite job: If-None-Match saves bandwidth, If-Match saves data.
The bug: last write wins, silently
Alice and Bob both open the same profile. Both GET version 1. Alice changes the display name, Bob changes the email. Each sends a PUT with the full document.
| Step | Who | Request | Stored state afterwards |
|---|---|---|---|
| 1 | Alice | GET /users/7 → ETag: "v1" | name=Al, email=old@x.io |
| 2 | Bob | GET /users/7 → ETag: "v1" | name=Al, email=old@x.io |
| 3 | Alice | PUT name=Alice | name=Alice, email=old@x.io |
| 4 | Bob | PUT email=new@x.io (name still "Al") | name=Al, email=new@x.io |
Both requests returned 200. Alice's rename is gone and nobody got an error. Retries, mobile clients on flaky networks and background syncs make this far more common than two humans racing.
Nothing in
PUTitself prevents this. APUTis "replace the resource with this", and the server is entitled to do exactly that. The protection has to be opted into with a precondition.
The fix: the same trace with If-Match
GET /users/7 HTTP/1.1
HTTP/1.1 200 OK
ETag: "v1"
Content-Type: application/json
{"name":"Al","email":"old@x.io"}Alice writes first and succeeds, and the server bumps the version:
PUT /users/7 HTTP/1.1
If-Match: "v1"
Content-Type: application/json
{"name":"Alice","email":"old@x.io"}
HTTP/1.1 200 OK
ETag: "v2"Bob's write carries the stale validator and bounces:
PUT /users/7 HTTP/1.1
If-Match: "v1"
Content-Type: application/json
{"name":"Al","email":"new@x.io"}
HTTP/1.1 412 Precondition Failed
ETag: "v2"
Content-Type: application/problem+json
{"type":"about:blank","title":"Resource changed","status":412}Bob's client now knows its copy is stale. It re-fetches (or reads the ETag off the 412), merges, and retries with If-Match: "v2". Whether to merge automatically or show a conflict dialog is a product decision; HTTP only guarantees you find out.
A Node handler that does it right
The check and the write must be one atomic step. Compare in application code and then update, and you've rebuilt the race one layer down.
app.put("/users/:id", async (req, res) => {
const ifMatch = req.get("If-Match");
if (!ifMatch) {
return res.status(428).json({ title: "If-Match header required" });
}
// version is an integer column; the ETag is just its quoted form
const expected = Number(ifMatch.replace(/^"|"$/g, ""));
const { rowCount, rows } = await db.query(
`UPDATE users SET name = $1, email = $2, version = version + 1
WHERE id = $3 AND version = $4
RETURNING version`,
[req.body.name, req.body.email, req.params.id, expected]
);
if (rowCount === 0) {
// either the row is gone (404) or the version moved (412)
const exists = await db.query("SELECT version FROM users WHERE id = $1", [req.params.id]);
if (exists.rowCount === 0) return res.sendStatus(404);
res.set("ETag", `"${exists.rows[0].version}"`);
return res.sendStatus(412);
}
res.set("ETag", `"${rows[0].version}"`).json({ ok: true });
});The WHERE ... AND version = $4 is the whole trick. The database does the compare-and-swap, so two concurrent requests can't both win. Same idea as UPDATE ... WHERE updated_at = ? in an ORM's "optimistic locking" feature; the HTTP layer just gives it a standard wire format.
Common mistakes
1. Using a weak ETag with If-Match
If-Match requires the strong comparison function (RFC 9110 §13.1.1). A W/"v1" validator never matches, so every write 412s. Weak ETags are fine for cache revalidation and wrong here. If a compression layer rewrites your ETags to weak, your writes break.
2. Making If-Match optional
If a client may omit the header, the safe clients are protected and the sloppy ones still clobber data, so the guarantee is worth little. Return 428 Precondition Required (RFC 6585) when it's missing. Yes, it breaks lazy clients; that is the point. Roll it out by logging first, enforcing later.
3. Hashing the response body as the ETag
A body hash changes when serialization changes (field order, a new nullable field), producing spurious 412s after a deploy. A version counter or updated_at column changes only when the data does.
4. Returning 409 or 400 instead
409 Conflict is legitimate for business-rule conflicts ("username taken"). For a failed precondition, 412 is the status clients and libraries recognize. Use If-Match and 412 for "your copy is stale", and 409 for everything else.
5. Forgetting If-Match on DELETE
Delete-after-update is a lost update too: Bob deletes a record that Alice just improved. DELETE accepts If-Match identically.
If-Match vs If-None-Match: *
Two preconditions people mix up:
| Header | Value | Meaning | Typical use |
|---|---|---|---|
If-Match | "v1" | Proceed only if the current version is v1 | Update or delete an existing resource safely |
If-None-Match | * | Proceed only if the resource does not exist | Create-only PUT, no overwrite of an existing key |
If-None-Match: * is how S3-style object stores implement "create if absent" without a read-then-write race.
FAQ
Does If-Match work with PATCH?
Yes. Preconditions are method-agnostic. It matters more for PATCH than PUT, because a JSON Merge Patch applied against a stale base can produce a document neither client ever saw.
What if my API has no ETag support yet?
Add a version integer to the row, expose it as ETag: "<version>" on GET and on every successful write, and add the WHERE version = ? clause. That's the entire migration. The ETag header is also a good place to answer "what changed" for cache revalidation later.
Is this the same as idempotency keys?
No. Idempotency keys make a retry of the same request safe. If-Match makes two different requests safe against each other. Payment-style endpoints often want both.
Can browsers' fetch send If-Match?
Yes. It is not a CORS-safelisted header, so cross-origin calls need it listed in Access-Control-Allow-Headers, and you should expose ETag through Access-Control-Expose-Headers or JavaScript can't read it. The CORS headers builder gets that pair right.
The short version
Optimistic concurrency is three small rules: GET returns a strong ETag, writes require If-Match (428 if absent), and the database compares and swaps in one statement (412 if it lost). That's enough to turn silent data loss into an error your client can handle.
Cover photo from Pexels.
