Optimistic concurrency over HTTP: ETag, If-Match, 412
An ETag sent back in If-Match turns a silent overwrite into a 412 the client can merge from.
Predict first
Before you read: predict
ExamHall's authoring API serves question 12's rubric at /rubrics/q12 with ETag: "v7" and evaluates If-Match on PUT as RFC 9110 defines. Authors Rana and Omar both open the rubric at version 7. Rana saves first, with If-Match: "v7", and gets 200. Omar then saves his edit with If-Match: "v7".
What does Omar's save get, and whose rubric is stored?
The 30-second version
A GET's ETag names the version you hold. Send it back in If-Match on the write: if the resource has moved on, the server performs nothing and answers 412 Precondition Failed, so you re-read and merge. The server must compare and write in one conditional statement, and hand back the tag that write created.
An ETag sent back in If-Match turns a silent overwrite into a 412 the client can merge from.
How it works
Step 1 of 12
ETag is an opaque tag for the representation it sent: a revision number or a content hash.In the kitchen
- every issued print carries a revision letter, "Rev C" → the ETag (step 1)
Analogy: A structural engineers' drawing office (engineering document control)
Site teams mark up printed drawings and send change requests back to the office that issues them.
| At the drawing office | In HTTP |
|---|---|
| every issued print carries a revision letter, "Rev C" | the ETag (step 1) |
| a change request quotes the revision it was marked up on | If-Match (step 2) |
| the register already shows Rev D when a Rev C markup arrives | a stale If-Match, then re-read and merge (steps 3–4) |
| the office posts back the new print, stamped with the revision this change made | the next tag in the 2xx (steps 5, 11) |
| a print is controlled, or a photocopy stamped "uncontrolled copy" | a strong tag, or a weak W/ one (step 6) |
| "issue drawing S-12 only if no S-12 exists yet" | If-None-Match: * (step 8) |
| a change request that quotes no revision | a write without If-Match (step 9) |
| the clerk checks the register and enters the change in one entry | one conditional statement (step 10) |
In real terms: before a write lands, ask which version this client saw, and let the database refuse the write if it is no longer that one.
Breaks down when: a clerk can read a site's markup and fold it into Rev D himself. A server cannot merge two edits for you; the client does that after the 412.
Contrasting case: a resource only one client ever writes, such as a user's own draft on one device. Nothing changes between its read and its write, so If-Match never fails.
Examples, run for real
4.1 ExamHall EXH-4990: two authors, one rubric
ExamHall's authoring app saved rubrics with a PUT that sent no condition, and one author's save silently erased another's (EXH-4990). MR !5290 requires If-Match and opens a merge view on 412:
question 12's rubric at version 7; Rana and Omar both open it; Rana saves, then Omar saves
before MR !5290 (saves send no condition) Rana 200 · Omar 200 · stored: Omar's rubric (Rana's edit lost)
MR !5290 (If-Match required) Rana 200, ETag "v8" · Omar 412 · stored: Rana's rubric
Omar's merge view GET 200, ETag "v8" · merged save with If-Match "v8" -> 200, ETag "v9" · stored: both edits
Omar's tag named version 7, so nothing was written (step 3). His merge view started from Rana's version and its new tag (steps 4–5).
4.2 Chat: two saves with one tag at the same moment
Two moderators save room r-3's topic together, both with If-Match: "v4". Two handlers, one forced order:
two moderators save room r-3's topic at once, both with If-Match "v4"
read the version, compare in code, then UPDATE by id both checks passed · A 200 · B 200 · stored: B's topic (A's lost)
one conditional UPDATE ... RETURNING version A 200 · B's UPDATE waited on A's row lock, then matched 0 rows · B 412 · stored: A's topic
Both checks read version 4 before either write, so each passed (step 10). The conditional UPDATE waited for A's commit, then re-checked its WHERE on A's row, as db-lost-update-strategies step 4 describes.
Picture it

Try it
Answer first, then check. Nothing is saved.
Question 1 of 7
A chat API's web framework marks every ETag weak: GET /messages/m-41 returns ETag: W/"v3". Its edit endpoint evaluates If-Match exactly as RFC 9110 requires. Lena opens message m-41, nobody else touches it, and she saves her edit with If-Match: W/"v3".
What does the save get?
Question 2 of 7
A chat client's request wrapper was written for caching: it sends each resource's stored ETag in If-None-Match on every request, saves included. The room-topic API (PUT /rooms/r-2/topic) follows RFC 9110 for both headers. Two complaints: saving a topic nobody else touched fails with 412, and some saves erase a colleague's newer topic.
Which one change fixes both?
Question 3 of 7
A chat API creates a room's settings with PUT /rooms/r-9/settings and If-None-Match: *, and evaluates that header as RFC 9110 defines. Each create runs in one database transaction that also writes an audit row. Two admins create r-9's settings at once: admin A's create has written its row but not committed when admin B's request arrives, and B's write waits on A's row. Then A commits and gets 201.
What does B get, and whose settings are stored?
Question 4 of 7
A chat API's topic save, after parsing If-Match: "v<n>" into n:
Ana saves room r-4's topic and gets 200. Ben saves his own topic and also gets 200. The database log shows Ben's UPDATE committed after Ana's UPDATE and before Ana's SELECT. Ana edits again on her open screen and saves with the ETag her 200 carried: 200 again, and Ben's topic is gone.
Which one change makes Ana's second save get 412 in that case and still succeed when nobody saved in between?
const r = await db.query( 'UPDATE rooms SET topic = $1, version = version + 1 WHERE id = $2 AND version = $3', [topic, path, n]);if (r.rowCount === 0) return reply(412);const { rows } = await db.query('SELECT version FROM rooms WHERE id = $1', [path]);return reply(200, { etag: `"v${rows[0].version}"` });Question 5 of 7
A chat API stores room topics in a rooms (id, topic, version) table. Its PUT /rooms/:id/topic handler must refuse a save with no If-Match, answer 412 to a stale tag and change nothing, answer 200 with the new ETag to a matching save, and end two saves sent at the same moment with one tag as one 200 and one 412.
How would you write the handler?
Question 6 of 7
A chat app's mobile client saves room r-5's topic with PUT /rooms/r-5/topic and If-Match: "v5". The server applies the save, but the connection drops before the reply arrives, and the client's network layer resends the identical request. The API evaluates If-Match as RFC 9110 defines and refuses every write whose precondition fails.
What does the resend get?
Question 7 of 7
A chat API evaluates If-Match on PUT /rooms/:id/topic when a request carries one; a save without it is applied as sent. The web app sends If-Match; mobile app 3.x sends no condition. A moderator corrects room r-6's topic on the web and gets 200; a member then saves from a mobile screen loaded before the correction, and the correction is gone.
Which one change makes such a save from an old screen fail, while web saves keep working?
Go deeper
Pro tips
- Test the overlap, not only the sequence. When: writing the test for a conditional save (step 10). Two saves one after the other give 200 and 412 even against a handler that reads, compares in code and then writes; only a forced overlap shows the difference. Verify (RAN,
tips.mjs):tip 1 read, compare in code, write: one after the other -> 200, 412 (the test passes) · forced overlap -> 200, 200, one topic lost tip 1 one conditional UPDATE: one after the other -> 200, 412 (the test passes) · forced overlap -> 200, 412, nothing lost - On a cross-origin API, expose the ETag. When: a browser app on another origin reads the tag to send it back (steps 1–2).
ETagis not a CORS-safelisted response header, so the page sees no tag unless the server lists it; the reading rule isnet-same-origin-corsstep 8. Source (cited, MDN, Access-Control-Expose-Headers): "Only the CORS-safelisted response headers are exposed by default. For clients to be able to access other headers, the server must list them using theAccess-Control-Expose-Headersheader."
Source
The TRUTH handler in lib/api.mjs implements the rules quoted here; the status codes the items grade are its executed replies. Valid as of 2026-10-18.
- RFC 9110, HTTP Semantics (the row's first source), https://www.rfc-editor.org/rfc/rfc9110.txt (
rfc9110.txt).- §8.8.3 ETag: "An entity tag is an opaque validator for differentiating between multiple representations of the same resource" (step 1); §8.8.3.1: "a resource that has implementation-specific versioning applied to all changes might use an internal revision number" (the pack's
"v<n>"tags). - §13.1.1 If-Match: "An origin server that evaluates an If-Match condition MUST NOT perform the requested method if the condition evaluates to false" (step 3); "the origin server MUST evaluate the If-Match condition per Section 13.2 prior to performing the method" (step 3). §8.8.3.2, §13.1.2 and §13.2.2 are cited for steps 6–8 and 12.
- §8.8.3 ETag: "An entity tag is an opaque validator for differentiating between multiple representations of the same resource" (step 1); §8.8.3.1: "a resource that has implementation-specific versioning applied to all changes might use an internal revision number" (the pack's
- MDN, If-Match (the row's second source), https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/If-Match, read from MDN's source repository (
mdn-if-match.md): "If-Matchcan be used to prevent the lost update problem" (steps 2–3). - MDN, 412 Precondition Failed, https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/412 (
mdn-412.md): "If the hashes don't match, the document has been edited in-between and a412 Precondition Failederror is thrown." (step 3). - Tools, not sources: PostgreSQL 17.11 (
postgres:17-alpine, local image), pg 8.23.0 (npm), whose behaviour the scripts grade as executed fact about those versions.
Related lessons
- Read first: HTTP method semantics, safe, idempotent, PUT vs PATCH vs POST
- Read first: Preventing lost updates
- Related: HTTP caching headers
- Related: Status codes and the error contract (RFC 9457 Problem Details)
- Related: Same-origin policy and CORS
- Related: Keys and constraints: UUIDv4, UUIDv7 or bigint, and constraints as the last line of defence
Checked against the tools and docs as of 2026-10-18.