Skip to content

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?

Pick one

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

The ETag names a version. A response's 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

a repairer in a green work jacket sits at a bench and holds a ring-shaped seal up beside a matching ring printed on an orange work order; a blue device on the bench carries a seal on its lid, and spare seals, a screwdriver and tweezers lie in front of it.
An illustration to help it stick. The mechanism is in the sections above.

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?

Pick one

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?

Pick one

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?

Pick one

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?

Code for this question
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}"` });
Pick one

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?

Pick one

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?

Pick one

Go deeper

Pro tips
  1. 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
  2. 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). ETag is not a CORS-safelisted response header, so the page sees no tag unless the server lists it; the reading rule is net-same-origin-cors step 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 the Access-Control-Expose-Headers header."
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.
  • 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-Match can 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 a 412 Precondition Failed error 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.

Checked against the tools and docs as of 2026-10-18.