skip to content

Two clients read the same HTTP resource, each edits it, and each sends a PUT. Both get 200 OK, but one client's edit has vanished. What happened, and what does HTTP offer to prevent it?

level: juniorimportance: must knowfreq 55%

answer

  1. read-modify-write gap
  2. GET ETag -> PUT If-Match
  3. mismatch = 412, nothing applied
  4. optimistic, no locks held
  5. check and write in one transaction

basics

~20 s

That is the lost update problem: the second PUT was computed from stale data and overwrote the first. HTTP prevents it with a conditional write - the client echoes the ETag it read in an If-Match header, and the server answers 412 Precondition Failed on mismatch.

solid answer

~50 s

Both clients performed read-modify-write. A fetched version 1, B fetched version 1, A wrote version 2, then B wrote its own edit - built from version 1 - on top, silently erasing A's change. A plain PUT carries no evidence that B never saw A's write, so the server has no basis to refuse it. HTTP's answer is the precondition. The GET response carries a validator, normally an `ETag`. On write the client sends that validator back in `If-Match`. The server compares it with the resource's current entity tag; if they differ the resource moved since the read, so the server applies nothing and returns **412 Precondition Failed**. The client re-fetches, re-applies its edit, and retries. This is optimistic concurrency: no locks are held, conflicts are detected at write time instead of being prevented up front.

code

http · 18 lines
http
GET /doc/7 HTTP/1.1
Host: api.example.com

HTTP/1.1 200 OK
ETag: "v1"
Content-Type: application/json

{"title":"Draft","body":"alpha"}

PUT /doc/7 HTTP/1.1
Host: api.example.com
If-Match: "v1"
Content-Type: application/json

{"title":"Final","body":"alpha"}

HTTP/1.1 200 OK
ETag: "v2"

go deeper

for a junior

Name the lost update, and show the GET-ETag then PUT-If-Match flow ending in 412 on mismatch.

for a middle

Add that this is optimistic concurrency, that 412 means nothing was applied, and that the client must re-fetch and retry.

for a senior

Stress atomicity of check-and-write, correct ETag generation, and the client-side conflict UX when an automatic retry is not safe.

for a principal

Frame it as choosing optimistic detection over pessimistic locking in a stateless protocol, and discuss where conflict resolution belongs - client merge, server merge rules, or user-visible conflict.

## The shape of the bug Almost every HTTP write is a **read-modify-write** cycle: the client GETs a representation, changes part of it, and PUTs the result back. There is a gap between the read and the write, and during that gap somebody else may write. 1. Client A: `GET /doc/7` -> title "Draft", body "alpha" 2. Client B: `GET /doc/7` -> the same representation 3. Client A: `PUT /doc/7` with title "Final" -> 200 OK 4. Client B: `PUT /doc/7` with body "beta", still carrying title "Draft" -> 200 OK The final state has title "Draft": A's change is gone, and nobody saw an error. This is the **lost update**. It is not a bug in the server code - the server did exactly what each request said. The missing information is *which version each client was editing*. ## Validators supply the missing version HTTP's fix is to make that version explicit. A response may carry a **validator** - a short opaque token identifying the current representation: - `ETag: "v1"` - an entity tag chosen by the server (a hash, a row version, a revision counter). - `Last-Modified: Tue, 12 Aug 2026 09:00:00 GMT` - a timestamp, weaker because its resolution is one second. The client stores whatever it received. When it writes, it repeats the value in a **precondition header**: - `If-Match: "v1"` on PUT/PATCH/DELETE means "only apply this if the resource is still exactly the version I read". - `If-Unmodified-Since: <date>` is the timestamp equivalent. The server evaluates the precondition **before** touching state. If the current ETag still matches, the write proceeds normally (200/204, usually with a fresh `ETag` for the new version). If it does not match, the server must not apply the change and returns **412 Precondition Failed** - a clean, deterministic "you are working from stale data". Replay the scenario with preconditions: A sends `If-Match: "v1"`, succeeds, and the resource becomes `"v2"`. B sends `If-Match: "v1"`, the server sees `"v2"`, and B gets 412. B's edit is not lost silently - it is rejected loudly, and B can re-fetch, re-apply, and retry, or show the user a conflict. ## Optimistic vs pessimistic This is **optimistic** concurrency control: assume conflicts are rare, take no lock, detect the collision at commit time. The pessimistic alternative - locking the resource between GET and PUT - does not fit HTTP well, because HTTP is stateless and a client that fetches and then disappears would hold the lock forever. Optimistic control is why HTTP has 412 at all. Two implementation notes matter. First, the comparison must be done *atomically with the update* - checking the ETag in application code and then writing in a separate transaction just moves the race window. Compare inside the same transaction, or push it into the storage layer (`UPDATE ... WHERE version = ?` and treat zero rows affected as 412). Second, the ETag must actually change whenever the representation changes, or the guard silently passes for stale writes. ## Which failure code 412 means "you sent a precondition and it failed". Do not use 409 Conflict for this - 409 is the general "request conflicts with resource state" code, while 412 tells the client precisely that its validator is stale and a re-fetch-and-retry is the remedy. And 200 with a body saying "conflict" is the worst option: clients and intermediaries treat it as success.

  • After a client receives 412 Precondition Failed, what should it do?
    Re-fetch the resource to obtain the current representation and its new ETag, re-apply the user's intended change on top of that fresh state, and retry the write with the new validator. If the change cannot be merged automatically, surface a conflict to the user rather than blindly retrying with If-Match dropped, which would reintroduce the lost update.
  • How do you make sure the precondition check and the write are actually atomic?
    Evaluate the validator inside the same transaction that performs the update, or fold it into the statement itself - for example UPDATE doc SET ... WHERE id = ? AND version = ?, treating zero affected rows as a 412. Reading the ETag, releasing the connection, and then writing leaves the same race you were trying to close.

Like editing a shared document by photocopying a page, marking it up, and handing it back: unless you write the page revision on your copy and the clerk checks it, your markup silently replaces whatever someone else filed in the meantime.

saying these in an interview costs you the question

  • Saying the server can detect the conflict without the client sending anything - a plain PUT carries no version information
  • Returning 409 Conflict for a failed If-Match instead of 412 Precondition Failed
  • Believing the server should keep the second write and merge silently - the whole point is to refuse it
  • Checking the ETag outside the write transaction, which leaves the race window open
  • Confusing this with caching: If-None-Match/304 is about saving bandwidth, If-Match/412 is about protecting state

context