Two clients open the same record, both edit it, and the second save silently overwrites the first. How would you use ETags to turn that lost update into something the client can see and handle?
Return a strong ETag on GET, require If-Match on writes, and reject a stale token with 412 Precondition Failed so a lost update becomes a visible error rather than silent data loss. What makes it correct is that the comparison and the write must be one atomic operation - an UPDATE guarded by the version in its WHERE clause - because a SELECT then UPDATE reintroduces the race.
What the interviewer is scoring
- Whether the candidate uses If-Match for writes and knows it is a different mechanism from If-None-Match for caching
- That 412 Precondition Failed is chosen deliberately, and the candidate can distinguish it from 409 Conflict
- Does the answer make the compare-and-swap atomic, rather than reading the version and then writing
- Whether they know If-Match requires a strong validator, so a weak W/ ETag cannot be used for this
- That the ETag is derived from something that actually changes on every write, not a second-granularity timestamp
- Whether the candidate says what the client should do on 412, rather than stopping at the status code
- Does the answer consider making the precondition mandatory with 428 rather than optional
Answer
Short answer
Give every representation a version token in an ETag header on GET, and require the client to echo it in If-Match on PUT, PATCH or DELETE. The server compares the token against the current state and rejects a stale one with 412 Precondition Failed. The lost update becomes an error the client can react to instead of data quietly disappearing.
The exchange
GET /invoices/482
200 OK
ETag: "7"
{ "id": 482, "status": "draft", "total": 1200 }
PUT /invoices/482
If-Match: "7"
{ "status": "approved", "total": 1200 }
200 OK
ETag: "8" <-- the token advances on every successful write
The second client is still holding "7". Its request arrives, the server sees the current version is "8", and returns:
412 Precondition Failed
Nothing was overwritten, and the client now knows why.
The part that is easy to get wrong
Most implementations of this are subtly broken, and it is the detail interviewers probe. The naive server does this:
var current = repo.findById(id);
if (!current.version().equals(ifMatch)) throw new PreconditionFailed();
repo.save(updated); // <-- another request can commit between these two lines
That is check-then-act, and it has exactly the race the ETag was introduced to eliminate — just a much narrower one. The comparison and the write have to be a single atomic operation, with the version in the WHERE clause:
UPDATE invoices
SET status = ?, total = ?, version = version + 1
WHERE id = ? AND version = ?; -- the ETag value
Then branch on the affected row count: one row means you won, zero rows means someone else committed first and the correct response is 412. The database is doing the compare-and-swap, which is the only place it can be done reliably. JPA's @Version generates precisely this statement and raises OptimisticLockException on zero rows, which maps cleanly onto the 412.
Choosing what the ETag is made of
The token must change on every write and must be cheap to compute. A monotonic version column is the best default: it is exact, it is already what your optimistic locking uses, and it cannot collide.
Two common alternatives have sharp edges. A hash of the serialised representation works, but it is a content hash — if a proxy re-serialises the JSON, reorders keys, or applies compression, the token changes without the resource changing, and every client gets spurious 412s. An updated_at timestamp is tempting and dangerous at one-second granularity: two writes inside the same second produce the same token, so the second overwrite is accepted and you are back to the lost update you were fixing, now with a false sense of safety.
There is also a specification constraint worth knowing. If-Match requires a strong validator. A weak ETag — the W/"..." form, meaning "semantically equivalent but not byte-identical" — is legal for caching with If-None-Match but must not be used for If-Match. If your framework emits weak ETags by default, as some do when compression is involved, optimistic concurrency will not work correctly on top of them.
412 or 409
Both appear in real APIs and the distinction is worth stating. 412 Precondition Failed means the precondition you sent evaluated false — the request was never applied because the resource had moved on. 409 Conflict means the request itself conflicts with the resource's state in a domain sense, such as approving an invoice that was already voided. Version staleness is a precondition failure. Reserve 409 for the business rule, and the client can then distinguish "you are out of date, refetch" from "this operation is not valid here", which are different things to tell a user.
Making the precondition mandatory
If-Match is optional by default, which means a client that never sends it gets the old last-write-wins behaviour and no warning. For any resource where concurrent edits are realistic, reject unconditional writes:
428 Precondition Required
{ "detail": "This resource requires an If-Match header." }
The trade-off is that this is a breaking change for existing clients, so in practice it is usually rolled out by logging unconditional writes first, finding out who is making them, and then enforcing. Saying that sequencing out loud — measure, then enforce — reads better in an interview than either extreme.
If-Match: * is the related special case: it means "only if the resource exists", which is how you make a PUT refuse to create.
What the client does with a 412
A status code is not a design, and this is where stronger answers separate themselves. The client has three reasonable options and the right one depends on the data. It can refetch and replay when the edit is a simple, idempotent field change. It can merge when the two edits touched different fields — refetch, apply only the fields this user actually changed, and resubmit with the new ETag. Or it can surface the conflict when the edits genuinely collide, showing what changed underneath and letting the user decide.
Silently retrying with the fresh ETag is the one option to avoid. It converts a detected conflict back into a lost update, which defeats the entire mechanism while appearing to handle it.
PATCH and DELETE
The mechanism is unchanged for DELETE — If-Match guards it the same way, so you cannot delete a record someone else has since modified. PATCH benefits most, because a patch is by definition applied to a base state, and If-Match is what pins which base state it was computed against. This is also the argument for keeping the version in the header rather than the request body: a header works uniformly across PUT, PATCH and DELETE, whereas a body field cannot guard a DELETE at all and duplicates a value the caching layer already understands.
© 2026 Preptima. Originally published at preptima.com.
Likely follow-ups
- A client omits If-Match entirely. What should the server do, and what does that choice cost you?
- Your ETag is a hash of the JSON response and a proxy re-serialises it. What breaks?
- How would this differ for PATCH compared with PUT? What about DELETE?
- When would you put a version field in the request body instead of using a header?
- The client gets a 412. Design the user experience from there.
Related questions
- How do you design the error contract for an API that other teams have to program against?hardAlso on api-design and http6 min
- Clients are asking for page 4,000 of your /orders collection. How is that endpoint paginated, and what would you change?mediumAlso on http and rest5 min
- Which status codes and method semantics do you insist on in a code review, how do the caching headers fit together, and is the API you just described REST?mediumAlso on http and rest6 min
- The same domain has to be exposed to a mobile app, a partner integration and internal service-to-service traffic. Where does GraphQL fit, where does gRPC, and where does neither?hardAlso on api-design7 min