HTTP conditional writes: detect an edit based on stale state
Use strong validators and atomic preconditions to prevent lost updates without confusing them with retry identity.
On this page
Key takeaways
- If-Match expresses the version an editor expects to replace.
- A validator prevents stale edits only when checking and mutation are atomic.
First principles
Two editors can read the same document and independently produce sensible changes. If the server blindly accepts both replacements, the second replacement erases the first editor's work. This is a lost update, not a malformed request. The missing information is the version each edit was based on. A conditional write supplies that information so the server can reject an edit whose assumptions no longer match current state.
An entity tag, usually called an ETag, is an opaque validator associated with a selected representation. A client reads the representation and remembers its validator. On a later write, If-Match asks the server to apply the operation only if the current validator strongly matches the supplied one. A failed precondition normally yields status 412. An API that requires conditional editing can reject a missing precondition with status 428 rather than silently permitting unconditional replacement.
Strong validation requires a meaningful relationship between validator and representation. A database revision is usable if every relevant representation change advances it and the serialization contract is controlled. If different negotiated representations reuse a validator despite differing bytes, the claimed strong validator may be wrong. Treat it as a representation contract, not merely a convenient integer. Weak validators, marked with W/, do not satisfy the strong comparison required by If-Match.
Runnable example
Save this as conditional-write.mjs and run it with Node.js. It models request handling without starting a server or fetching anything. Only a single quoted validator is supported in this deliberately narrow API; a general HTTP implementation must handle the full header grammar.
import assert from 'node:assert/strict';
let document = { revision: 1, title: 'Draft' };
const etag = () => `"doc-${document.revision}"`;
function read() {
return { status: 200, etag: etag(), body: { title: document.title } };
}
function replace(ifMatch, title) {
if (ifMatch === undefined) return { status: 428 };
if (typeof ifMatch !== 'string' || !/^"doc-[1-9][0-9]*"$/.test(ifMatch)) {
return { status: 400 };
}
if (typeof title !== 'string' || title.length === 0 || title.length > 80) {
return { status: 400 };
}
if (ifMatch !== etag()) return { status: 412 };
document = { revision: document.revision + 1, title };
return { status: 200, etag: etag() };
}
const firstEditor = read();
const secondEditor = read();
assert.equal(replace(firstEditor.etag, 'Reviewed').status, 200);
assert.equal(replace(secondEditor.etag, 'Published').status, 412);
assert.equal(replace(undefined, 'Unchecked').status, 428);
assert.equal(read().body.title, 'Reviewed');
console.log('accepted current edit; rejected stale edit');
The state check and update execute without an asynchronous gap in this toy process. A production service with multiple workers must use a storage-side condition, such as updating where both document identity and expected revision match. Checking the revision in application memory and then issuing an unconditional write reintroduces the race. Return the new validator from the committed result, not from a prediction made before another writer can intervene.
Contextual failure case
A profile editor reads revision ten and submits a new display name. Another device changes notification settings before the first submission arrives. If the first device sends its entire old profile without a precondition, it can restore the old notification settings unintentionally. The user sees a successful save while an unrelated field silently regresses. With conditional replacement, the stale editor must fetch current state and make an explicit merge decision rather than winning by arrival order.
The deeper ambiguity appears when the server commits an edit but its response is lost. Repeating the same request with the old If-Match value may produce 412 because the first request advanced the revision. That response does not prove the original attempt failed. A validator controls the state transition's starting version; it does not identify repeated attempts as one logical operation. If the client needs safe automatic retries and result recovery, add a scoped idempotency contract rather than interpreting every precondition failure as a fresh conflict.
Verification
The example prints accepted current edit; rejected stale edit. Add a test for a weak validator and a malformed title, and confirm the document remains unchanged after each rejected request. Inspect synthetic edit bodies with payload tools to make full replacements and partial updates distinguishable. For a real endpoint, test two clients starting from the same revision and force both storage operations to contend; exactly one should commit for that expected revision.
Also verify that all mutation paths advance the validator. An administrative update that bypasses revision maintenance defeats the entire contract even when the public endpoint checks If-Match correctly. Test representation variants if the endpoint negotiates different output shapes. Headers should remain coherent with the actual selected representation after filtering, localization, or serialization changes.
Exercise
Add a client state machine with loaded, saving, saved, and conflict states. After 412, preserve the user's proposed edit while loading the current document. Show the two values to a human rather than silently retrying a full replacement. Explain when a field-level merge is safe and when the business invariant spans several fields, making independent merges invalid. Keep operation identity separate from the expected representation version.
Safety and caveats
Conditional headers are not authorization and must not reveal a resource across tenant boundaries. Validate permissions before exposing current content or validators to unauthorized callers. Keep sensitive information out of ETag values; use opaque revision tokens rather than embedded personal data. The wildcard If-Match form tests existence, not a specific version, so it does not provide the same edit protection. Version counters must not be reused after deletion and recreation if old validators could accidentally match the new object.