The stale payload is evidence, not permission.
The document starts at count 10, version 1. Editors A and B both read version
1. A commits first, moving the count to 11 and the version to 2. B's payload still says “I
based this on version 1.”
Without a version check, B can write from its stale copy and silently erase A's increment.
With a compare-and-swap condition such as WHERE version = 1, storage rejects B
because the current version is 2.
The second writer must not publish against a version it did not read.
- Initial
count 10 · version 1- A commits
count 11 · version 2- B expects
- Version 1, from the read that produced its payload
- Mismatch
- Stored version 2 proves B's read is stale
- Recovery
- Reject, retry, merge when valid, or ask the caller
Detection is general; resolution belongs to the domain.
A retry can be right when the operation can be safely reapplied to fresh state. A merge can be right when edits are independent or commutative. A same-field title conflict needs a caller decision. Rejecting is always more informative than silently overwriting, while retrying forever can turn a hot resource into a loop.
Safe default when the caller must see both versions or choose again.
Useful for bounded, idempotent operations whose intent survives a reread.
Combine only when the fields and invariants make the combination explicit.
The stale payload wins by arrival order and erases evidence of the conflict.
Compare-and-swap in one sentenceThe write condition carries the read
Update the record only if its stored version still equals the version the caller read; if zero rows change, report a conflict and do not pretend the write succeeded. The same idea appears in conditional updates, entity tags, and compare-and-swap primitives.
Run the stale write through different responses.
The lab runs the displayed TypeScript model. Start with a stale increment and reject it. Then retry from version 2, try a domain-aware merge, and finally compare the unsafe overwrite. Change the edit to a same-field rename to see why merge semantics cannot be assumed.
What should the stale editor do?
Keep the interleaving fixed. Change the edit shape or conflict resolution.
Reject the stale payload and expose the conflict. Both editors read version 1; editor A commits version 2 first.
Start with a rejected stale increment, then retry it and compare the final count.
This lab models one versioned document and two edits. It does not provide a universal merge rule, lock, retry budget, or multi-row transaction.
A conflict needs a bounded next stepRetry, merge, or explain
Return enough information for the caller to make the next move: current version, submitted version, current value, submitted intent, and whether the operation can be retried. Add a retry budget and backoff for automatic paths; surface a useful conflict for interactive paths.
Hold the conflict contract steady. Change the language.
Both editors carry the version they read, and the second write checks it before publishing.
export function runConcurrency(
edit: Edit = 'increment',
resolution: Resolution = 'reject'
): ConcurrencyRun {
const initial = copyState(initialDocument);
const secondRead = copyState(initial);
const afterFirstCommit = copyState(initial);
applyEdit(afterFirstCommit, edit, 'A');
const expectedVersion = secondRead.version;
const actualVersion = afterFirstCommit.version;
const conflictDetected = expectedVersion !== actualVersion;
let final = copyState(afterFirstCommit);
let secondCommitted = false;
let outcome = 'conflict rejected';
let explanation =
'The compare-and-swap check saw version 2 instead of the expected version 1, so the stale write did not publish.';
if (resolution === 'retry') {
applyEdit(final, edit, 'B');
secondCommitted = true;
outcome = 'retry committed';
explanation =
'B reread version 2, reapplied its edit to the fresh state, and committed version 3.';
} else if (resolution === 'merge') {
if (edit === 'increment') {
applyEdit(final, edit, 'B');
secondCommitted = true;
outcome = 'merged commit';
explanation =
'Both edits are increments, so the domain can combine them as two changes and publish version 3.';
} else {
outcome = 'manual merge required';
explanation =
'Both editors changed the same title field. The version check found the conflict, but the model cannot choose which wording should win.';
}
} else if (resolution === 'ask') {
outcome = 'caller must resolve';
explanation =
'The write is rejected with the conflict details so the caller can show both versions or ask for a new edit.';
} else if (resolution === 'overwrite') {
const staleWrite = copyState(secondRead);
applyEdit(staleWrite, edit, 'B');
staleWrite.version = actualVersion + 1;
final = staleWrite;
secondCommitted = true;
outcome = 'lost update';
explanation =
'The stale payload overwrote the state from version 2. For an increment, A’s change disappears; for a rename, B silently wins.';
}
return {
edit,
resolution,
initial,
afterFirstCommit,
final,
expectedVersion,
actualVersion,
conflictDetected,
firstCommitted: true,
secondCommitted,
outcome,
explanation
};
} func RunConcurrency(edit Edit, resolution Resolution) ConcurrencyRun {
initial := initialDocument
secondRead := initial
afterFirstCommit := initial
applyEdit(&afterFirstCommit, edit, "A")
expectedVersion := secondRead.Version
actualVersion := afterFirstCommit.Version
conflictDetected := expectedVersion != actualVersion
final := afterFirstCommit
secondCommitted := false
outcome := "conflict rejected"
explanation := "The compare-and-swap check saw version 2 instead of the expected version 1, so the stale write did not publish."
switch resolution {
case Retry:
applyEdit(&final, edit, "B")
secondCommitted = true
outcome = "retry committed"
explanation = "B reread version 2, reapplied its edit to the fresh state, and committed version 3."
case Merge:
if edit == Increment {
applyEdit(&final, edit, "B")
secondCommitted = true
outcome = "merged commit"
explanation = "Both edits are increments, so the domain can combine them as two changes and publish version 3."
} else {
outcome = "manual merge required"
explanation = "Both editors changed the same title field. The version check found the conflict, but the model cannot choose which wording should win."
}
case Ask:
outcome = "caller must resolve"
explanation = "The write is rejected with the conflict details so the caller can show both versions or ask for a new edit."
case Overwrite:
staleWrite := secondRead
applyEdit(&staleWrite, edit, "B")
staleWrite.Version = actualVersion + 1
final = staleWrite
secondCommitted = true
outcome = "lost update"
explanation = "The stale payload overwrote the state from version 2. For an increment, A's change disappears; for a rename, B silently wins."
}
return ConcurrencyRun{
Edit: edit, Resolution: resolution, Initial: initial, AfterFirstCommit: afterFirstCommit,
Final: final, ExpectedVersion: expectedVersion, ActualVersion: actualVersion,
ConflictDetected: conflictDetected, FirstCommitted: true, SecondCommitted: secondCommitted,
Outcome: outcome, Explanation: explanation,
}
} Both examples preserve the same read version, first commit, mismatch, resolution choices, and final state. The language changes the state representation; it does not change the conflict contract.
Copy the complete examplesStandard library only
These files model a conditional write without pretending to be a database driver. Apply the expected-version condition in the storage system, then keep the recovery tests around the adapter.
TypeScriptnode --experimental-strip-types document.ts
Gogo run document.go
A version protects a write; it does not define intent.
This lesson establishes stale-write detection and a few bounded recovery paths for one versioned document. It does not establish a universal merge algorithm, transaction isolation, multi-row invariants, distributed ordering, or safe retry semantics for arbitrary side effects.
Define what the version covers, how a conditional write is atomic, what the caller sees on a conflict, and how retries remain idempotent. Hot keys may need a different design: partitioning, serialization, queues, or a deliberate lock.
Build UIs?See where this shows up in your components.
Do not call every retry a merge.
A retry reapplies one known operation to new state. A merge combines competing intents. The caller needs to know which one happened so audit, notifications, and user expectations remain aligned.
Make the stale update explainable.
A save arrives with an older version than the one in storage. Which next move preserves the conflict while still giving the caller a path forward?
A save arrives with an older version than the one in storage.
What makes the update safe to explain?
Make the next overlap cheaper to resolve.
Record the version read, version submitted, conditional-write result, recovery rule, retry budget, and the side effects that still need idempotency.
- Why
- Two editors can read the same version, and a stale save must not silently erase a committed change.
- What
- Send the expected version, update only if the stored version still matches, and report a conflict when zero rows change.
- Constraint
- The version covers the whole record, the conditional write is atomic, and side effects need their own idempotency.
- Fallback
- Retry within a budget when reapplying is safe, merge only independent fields, and otherwise ask the caller.
- Reconsider when
- A hot key keeps conflicting, the invariant spans several rows, or retries start to loop.
A conflict note to adapt to your own write path. Nothing here is saved to an account.
Explore more concepts & practices →