skip to content

A team exposes only HTTP PUT for updates, and each client sends the full resource representation it knows about. What goes wrong as the schema evolves and multiple clients write concurrently, and how would you fix the contract?

level: seniorimportance: should knowfreq 38%

answer

  1. PUT = client owns whole representation
  2. Old client omits new field → silent delete
  3. Idempotent ≠ concurrency-safe
  4. ETag on GET, If-Match on write, 412 on mismatch
  5. 428 to require the precondition

basics

~20 s

Clients built against an older schema omit newer fields, so every PUT silently deletes them; and read-modify-write races mean the last writer clobbers concurrent edits it never saw. Fix with PATCH for partial edits, ETag plus If-Match for conflict detection, and explicit read-only field rules.

solid answer

~50 s

Two independent failures. **Schema drift.** PUT means "the resource is now exactly this body". A client compiled against v1 does not know about `loyaltyTier` added in v2, so its PUT drops it. Nothing errors; data just disappears, usually noticed weeks later. **Lost updates.** Every PUT client does GET → modify → PUT. Two clients that read the same version and write back both send a full representation; the second overwrites the first's change even though they touched different fields. PUT's idempotency does not help — that only says repeating *your* request is safe. Fixes: offer PATCH (merge patch) so clients send only what they intend to change; require optimistic concurrency — return an `ETag` on GET and demand `If-Match` on writes, answering `412 Precondition Failed` on mismatch; declare server-owned fields read-only and ignore or reject them on input; and return the stored representation on `200` so clients can see the real post-state.

go deeper

for a junior

Recognise that PUT replaces everything, so a client that omits a field deletes it.

for a middle

Add the read-modify-write race and name ETag plus If-Match as the mechanism that detects it.

for a senior

Lay out the full remediation: PATCH for edits, required preconditions with 412/428, read-only field policy, returning the representation.

for a principal

Decide the platform stance on concurrency control — mandatory optimistic locking, which validator, and how conflicts are surfaced to end-user UIs across services.

## What PUT actually promises PUT says: after this request, the resource at this URL equals the body. That is a strong, useful promise — it makes retries safe and makes the endpoint easy to reason about. It also means the *client* is authoritative over the entire representation, and that is where the trouble starts once more than one client, or more than one schema version, exists. ## Failure 1: silent field deletion under schema evolution Add a field `loyaltyTier` to the order resource. Existing clients do not know it. Their next update does: ``` PUT /orders/42 {"item":"book","qty":1,"status":"paid"} ``` A faithful PUT implementation removes `loyaltyTier`. No error is raised — the client did exactly what PUT asks. The damage is invisible at the HTTP layer and shows up as "customers randomly lose their tier". Worse, many servers are *not* faithful: they bind the body onto the entity and save, so absent fields become null in the DTO and then null in the database — the same outcome by a different route, plus inconsistency between fields that happen to have defaults and fields that do not. Mitigations: - **Prefer PATCH for edits.** A merge patch only carries what the client intends to change, so unknown fields are structurally safe. - **Make clients round-trip.** If PUT must stay, require the client to GET the current representation, modify it, and PUT the result *including fields it does not understand*. That works only if the client preserves unknown fields — most generated SDKs strip them. - **Keep server-owned fields out of the representation.** Fields the client can never author (`createdAt`, `version`, derived totals) should be documented read-only, ignored on input, and ideally moved into a sub-resource or a separate read model so they cannot be dropped at all. ## Failure 2: lost updates PUT is idempotent, which candidates often mistake for "concurrency-safe". Idempotent means replaying *the same* request does not change the outcome. It says nothing about two *different* requests interleaving. ``` A: GET /orders/42 → {qty:1, note:"gift"} B: GET /orders/42 → {qty:1, note:"gift"} A: PUT {qty:2, note:"gift"} B: PUT {qty:1, note:"rush"} ← A's qty change is gone ``` Both wrote a full representation; B's was built from stale data. The API gave the clients no way to notice. The standard fix is optimistic concurrency using HTTP's own conditional-request machinery: the server returns a validator (`ETag: "v7"`) with the representation, the client echoes it (`If-Match: "v7"`) on the write, and the server rejects with `412 Precondition Failed` when the current validator differs. The client then re-reads, re-applies its change, and retries. Backed by a version column, this is a compare-and-swap; the ETag can literally be the row version. Making `If-Match` **required** on PUT (answering `428 Precondition Required` when it is missing) is the strong form and is what write-heavy multi-client APIs converge on. Making it optional means the well-behaved clients are protected and the others still clobber. PATCH narrows the window but does not close it: two merge patches touching the same field still race, and array-replacing merge patches race badly. Conditional requests remain the answer. ## Designing the fix A workable contract for a shared, evolving resource looks like: 1. `GET /orders/42` returns the representation plus `ETag`. 2. `PATCH /orders/42` with `application/merge-patch+json` for ordinary edits, `If-Match` required. 3. `PUT /orders/42` kept only where whole-document replacement is genuinely meaningful (a config blob, a client-authored document), `If-Match` required there too. 4. Read-only fields documented and rejected or ignored consistently on input. 5. Successful writes return `200` with the new representation so clients converge rather than guess. 6. Field-level conflicts that cannot be resolved by re-read-and-retry — two users editing the same text — surface as `409 Conflict` with enough detail for a UI to show a merge. ## What interviewers are testing Whether you understand that idempotency and concurrency safety are different properties, and whether you connect "full replacement" to real data loss. Strong answers name ETag/If-Match/412 concretely and mention that PATCH reduces but does not eliminate the race.

  • Why does PUT being idempotent not protect against lost updates?
    Idempotency is about replaying the same request: sending your PUT twice leaves the same state. Lost updates come from two different requests built on the same stale read, where the second overwrites fields the first changed. Detecting that needs a validator carried through the read-modify-write cycle — an ETag echoed as If-Match — not idempotency.
  • What should the server return when the If-Match validator does not match?
    412 Precondition Failed, meaning the resource changed since the client read it; the client should re-fetch, re-apply its change, and retry. If the request omits If-Match entirely and your contract requires it, 428 Precondition Required tells the client to add the header rather than leaving it guessing. Reserve 409 for conflicts the client cannot fix by simply re-reading.

saying these in an interview costs you the question

  • Saying PUT is safe under concurrency because it is idempotent
  • Implementing PUT as a merge so omitted fields survive, while still calling the method PUT
  • Relying on clients to round-trip fields they do not understand, when SDKs strip unknown fields
  • Adding a version field to the body but never checking it server-side
  • Using 409 for every stale-write case instead of 412 with a conditional request

context