A document editor saves automatically every two seconds of idle time. Clients are on flaky mobile links so a save can time out and be retried after it already applied, a user can have the same document open in two tabs, and a validation failure on one field must not discard the user's other edits. Design the write path's API contract - what the request carries, what the 409 and 422 responses must carry, and what the client does with each.
Show the full answer Hide the answer
Requirements that drive the shape
Three facts decide everything: a request can be applied without the client learning that, two writers exist (two tabs count), and a rejection must be partial-information rather than all-or-nothing silence. A read-shaped API discussion about payload size is irrelevant here.
The design
Request: PATCH /documents/{id} carrying
- a client-generated idempotency key - one UUID per logical save, reused unchanged across every retry of that save;
- an
If-Matchprecondition holding the revision the client last saw; - only the changed fields, as a sparse patch keyed by field path.
200 returns the new revision and the server-canonical values of the fields it changed, so the client reconciles normalisation (trimmed whitespace, rounded numbers) instead of drifting from the server.
409, the precondition failure, must carry the current revision, the server's current values for the conflicting fields only, and a conflicts list keyed by field path. With that, the client merges the fields that do not overlap and prompts the user only for the ones it cannot. Without the field-level list, every conflict becomes "reload and lose your work".
422, the validation failure, must carry field-keyed errors - path, machine code, message - and an explicit statement of what was applied. Make the write all-or-nothing and say so in the response. Partial application without a manifest of what landed is the defect that loses user work, because the client's local state is now wrong in a way it cannot detect.
Idempotency storage: the server keeps key to (revision, response) for a window - 24 hours is a common choice - and a retry with the same key replays the original response rather than evaluating the precondition again.
The hard part
The retry that already succeeded. The save applied, the response was lost to a dropped connection, the client retries with a stale If-Match, and the server correctly answers 409. The user is now shown a conflict dialog describing a conflict with themselves - the single most common autosave bug in editors, and the reason the idempotency key is not optional decoration. With the key, the retry returns the original 200 and the client's revision moves forward.
What it costs
A key store with a TTL, revision bookkeeping on every entity, and a client that must hold the key stable across retries - which rules out generating it inside the fetch wrapper. On a 150 ms round trip a save costs roughly 300-400 ms, so debounce and coalesce pending saves into one in-flight request, or a fast typist generates a queue the precondition will reject.
How it fails
- The key is regenerated per attempt: idempotency silently does nothing and duplicate revisions appear.
If-Matchomitted: last write wins, and the second tab's save erases the first's with no 409 to notice.- Errors returned as a prose string: the client cannot attach them to fields, so it shows a toast and the user hunts for the bad field.
What I would build first
The revision precondition and the field-keyed error shape, in that order - they stop silent data loss and make failures actionable. Idempotency keys next, before the first mobile release. Field-level conflict merging last; a 409 that says which fields conflict is already enough for a usable prompt.
When not to build this
Single tab, one author, last-write-wins acceptable - a personal draft nobody else touches - makes a plain PUT with no precondition correct, and the whole apparatus is waste. The flip is specific: a second concurrent writer, or an edit whose loss the user would call a bug rather than an inconvenience.