Two Clients, One Record
ETag and If-Match turn a lost update into a 412 the client can act on, instead of a silent overwrite.
If-Match: "42" -> 200 OK | 412 Precondition FailedTwo support agents open ticket 77. One edits the subject, the other edits the body. Both PATCH. The second write lands on a copy read before the first, so the first agent's change is gone — and both requests returned 200. That is a lost update, and its defining property is that nothing anywhere reports an error.
ETag and If-Match
Return an ETag on GET that identifies the version the client now holds. Require the write to echo it as If-Match. If it no longer matches, answer 412 Precondition Failed: per RFC 9110, "one or more conditions given in the request header fields evaluated to false when tested on the server". If-Match uses strong comparison, so a weak entity tag (W/"...") never satisfies it. If the client omits the header entirely, 428 Precondition Required (RFC 6585) tells it what to send; accepting the write unconditionally reopens the hole you just closed.
412 against 409
412 means "you were working from a stale copy" — purely mechanical, and a client can handle it in a loop: re-read, re-apply, retry. 409 means the conflict is semantic and a retry of the same request is still wrong, so a human or a different decision is needed. Returning 409 for staleness denies the client the one response it could have automated.
A version field in the body
Echoing a version integer and doing UPDATE ... WHERE version = $n is the same idea with worse ergonomics: caches, proxies and HTTP libraries understand If-Match and understand nothing about your field. You usually need the column anyway for the compare-and-set — so expose it as the ETag.
Example
// Express's default ETag is weak; we emit a strong one from the row version,
// so the HTTP precondition and the SQL compare-and-set share one source of truth.
app.set('etag', false);
app.get('/tickets/:id', async (req, res) => {
const t = await db.oneOrNone(
'SELECT id, subject, body, version FROM tickets WHERE id = $1', [req.params.id]);
if (!t) return res.status(404).end();
res.set('ETag', `"${t.version}"`); // res.set === res.setHeader in Express
res.json(t);
});
app.patch('/tickets/:id', async (req, res) => {
const ifMatch = req.get('If-Match');
if (!ifMatch) {
// 428 = "send a precondition", distinct from 412 = "yours failed".
return res.status(428).json(problem('precondition-required', 428));
}
if (ifMatch.startsWith('W/')) {
// A weak tag can never satisfy If-Match, so say so rather than guessing.
return res.status(400).json(problem('weak-etag-not-allowed', 400));
}
const expected = Number(ifMatch.replaceAll('"', ''));
// Compare-and-set in one statement. rowCount is the answer.
const { rowCount, rows } = await db.query(
`UPDATE tickets
SET subject = COALESCE($1, subject),
body = COALESCE($2, body),
version = version + 1
WHERE id = $3 AND version = $4
RETURNING id, subject, body, version`,
[req.body.subject ?? null, req.body.body ?? null, req.params.id, expected]);
if (rowCount === 0) {
// Distinguish "gone" from "stale" — the client reacts differently.
const exists = await db.oneOrNone('SELECT 1 FROM tickets WHERE id = $1', [req.params.id]);
return exists
? res.status(412).json(problem('stale-etag', 412))
: res.status(404).end();
}
res.set('ETag', `"${rows[0].version}"`).json(rows[0]);
});When to use it
- A PATCH endpoint where two admin users silently overwrite each other's edits
- Adding optimistic concurrency to an existing resource without a schema migration
- Deciding whether a stale write should return 409 or 412 so the SDK can retry it automatically
- Debugging why If-Match never matches behind a proxy that rewrites ETags
More examples
Why the check has to be in the UPDATE
The two-statement form is the bug behind most 'we added optimistic locking and it still overwrites' reports: between the SELECT and the UPDATE there is no lock at all. The hash variant is useful on a legacy table you are not allowed to migrate, at the cost of recomputing the hash on read.
-- BROKEN: two sessions both read version 7 and both pass the check.
SELECT version FROM tickets WHERE id = 77; -- 7 in both sessions
-- ... application compares 7 = 7, then:
UPDATE tickets SET subject = $1, version = 8 WHERE id = 77; -- last one wins
-- CORRECT: the predicate is part of the write, so one session loses.
UPDATE tickets
SET subject = $1, version = version + 1
WHERE id = 77 AND version = 7;
-- UPDATE 1 -> we won, 200 with the new ETag
-- UPDATE 0 -> someone else moved it, 412
-- The same shape with a content hash instead of a counter, when you have
-- no version column to add:
UPDATE tickets SET subject = $1
WHERE id = 77 AND md5(subject || body) = $2;The client loop a 412 makes possible
This is the payoff for returning 412 rather than 409 — the retry is mechanical and bounded. Note that `newSubject` is a function of the current value: a loop that re-sends a value computed before the first read will keep clobbering the other writer even though every request now carries a precondition.
async function renameTicket(id, newSubject, attempts = 3) {
for (let i = 0; i < attempts; i++) {
const read = await fetch(`/tickets/${id}`);
const etag = read.headers.get('ETag');
const ticket = await read.json();
const write = await fetch(`/tickets/${id}`, {
method: 'PATCH',
headers: { 'If-Match': etag, 'Content-Type': 'application/json' },
// Merge against the copy we just read, not against stale local state.
body: JSON.stringify({ subject: newSubject(ticket.subject) }),
});
if (write.ok) return write.json();
if (write.status !== 412) throw new Error(`patch failed: ${write.status}`);
// 412: someone else wrote in between. Re-read and re-apply.
}
throw new Error('too much contention on ticket ' + id);
}When the version lives in the body instead
Reach for the body field only when you cannot control the response headers — behind a gateway that strips or rewrites ETag, or in a GraphQL or JSON-RPC surface where there is no per-resource ETag to begin with. If you do, document that the field is a precondition and not a writable attribute, or clients will send it back as data.
GET /tickets/77
200 OK
{"id": 77, "subject": "Printer offline", "version": 7}
# Same mechanism, worse ergonomics: the precondition is now payload,
# so no cache, proxy or generic HTTP client can see or enforce it,
# and PUT/PATCH bodies must carry a field that is not really data.
PATCH /tickets/77
{"subject": "Printer offline in 3F", "version": 7}
409 Conflict
{"type": ".../version-conflict", "current_version": 9}
# The header form, for comparison. Same database predicate underneath.
PATCH /tickets/77
If-Match: "7"
{"subject": "Printer offline in 3F"}
412 Precondition Failed
Discussion