The question this answers
Can I update shared state without holding a lock while I decide what to write?
Two support agents editing the same customer record in a web console, thirty seconds apart in intent and forty milliseconds apart in arrival.
The customer record and its version counter. Nothing is locked between read and write; the version field is the only coordination, and it is the only thing both writers must agree on.
No committed write is silently overwritten by a write computed from an older version — every stored value is derived from the state that was current when the writer decided.
Synchronization exists to preserve this sentence. If a schedule can make it false, the code is wrong no matter which primitive it uses.
The mechanism is a bet, and the version is how you settle it
Pessimistic control locks first and asks questions later: you hold the row while you think. Optimistic control does the opposite — it assumes conflicts are rare, does all the thinking with nothing held, and validates at the last possible moment. The validation is the whole design: UPDATE ... WHERE id = ? AND version = 5, and if it reports zero rows affected, the bet lost.
That single condition is what turns a read-modify-write into something safe. Without it, the second writer stores a value computed from a state that no longer exists, and the first writer's change disappears with no error anywhere — the lost update. With it, the second writer finds out, and gets to decide what to do about it.
The same shape appears at every scale. In memory it is Compare-and-Swap and the Retry Loop on a word. In a data structure it is the pointer swap of Copy-on-Write as a Concurrency Strategy. In a database it is a version column or MVCC: Multi-Version Concurrency Control conflict detection. Over HTTP it is an ETag and a 412 Precondition Failed — and that contract is API Design's lesson, optimistic-concurrency, not this one. What is taught here is the mechanism underneath and the retry loop on top.
| # | Agent A (sets tier=gold) | Agent B (sets phone=+49...) | State |
|---|---|---|---|
| 1 | SELECT * FROM customer WHERE id=42 -> v5 {tier: silver, phone: old} | · | version=5 tier=silver |
| 2 | · | SELECT * FROM customer WHERE id=42 -> v5 {tier: silver, phone: old} | version=5 tier=silver |
| 3 | UPDATE ... SET tier=gold, version=6 WHERE id=42 | · | version=6 tier=gold phone=old |
| 4 | · | UPDATE ... SET phone=new, version=6 WHERE id=42 (no version predicate) | version=6 tier=silver phone=new ✕ B wrote the whole record from its v5 read, so tier reverted to silver. Both agents saw "Saved" and one change is gone. |
| 5 | · | --- rerun with the predicate --- | version=6 tier=gold |
| 6 | · | UPDATE ... SET phone=new, version=7 WHERE id=42 AND version=5 -> 0 rows | version=6 tier=gold phone=old |
| 7 | · | re-read v6, recompute (tier=gold, phone=new), UPDATE ... WHERE version=6 -> 1 row | version=7 tier=gold phone=new |
The retry loop, and the storm inside it
Detection is half the design. The other half is what happens on failure, and this is where most implementations are quietly wrong. Retrying immediately, forever, with no jitter, is not a retry policy — it is a positive feedback loop. Under contention every loser retries at once, contends again, and loses again, so the system spends increasing CPU on work it will throw away. Throughput does not degrade gracefully; it falls off a cliff, because the retries themselves are the load.
Three rules keep the loop honest. Bound the attempts, so a pathological key cannot consume a request thread forever. Back off with jitter, so the losers do not re-collide in lockstep. And recompute from the fresh state rather than replaying the diff — the whole premise is that the world moved, so re-reading is the point of the retry, not an overhead to optimize away.
The fourth rule is the one that gets skipped: decide whether retrying is even correct. Retrying "set tier=gold" is fine. Retrying "increment balance by 100" is fine. Retrying "set phone to what the agent typed" against a record whose phone someone else just changed is a policy decision, not a mechanical one — sometimes the right answer is to fail loudly and show the human the conflict.
1const MAX_ATTEMPTS = 52 3async function updateCustomer(id: string, change: Change): Promise<Customer> {4 for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {5 const cur = await load(id) // fresh read every attempt6 const next = apply(cur, change) // recompute, never replay7 const rows = await db.update(8 'UPDATE customer SET data = $1, version = $2 WHERE id = $3 AND version = $4',9 [next, cur.version + 1, id, cur.version],10 )11 if (rows === 1) return next // won the bet12 13 // Lost. Back off with full jitter so losers do not re-collide in lockstep.14 const cap = Math.min(200, 10 * 2 ** attempt)15 await sleep(Math.random() * cap)16 }17 // Bounded: a hot key must not own a request thread indefinitely.18 throw new ConflictError(id, MAX_ATTEMPTS)19}What the signal looks like when the bet stops paying
Optimistic control has an unusually clear operational signature, which is its best property. The conflict rate is a first-class metric: attempts per successful commit. At a healthy 1.02 the strategy is doing exactly what it promised. At 3.8 you are burning most of your database round trips on work you discard, and at that point pessimistic locking on the hot key is not a regression — it is the cheaper design.
The distribution matters more than the mean, because contention is almost never uniform. A system with a 1.05 average conflict rate can still have one tenant, one product SKU or one counter row at 12 attempts per commit. Aggregate metrics hide that completely; per-key conflict counts expose it, and the fix is usually to shard the hot key rather than to change strategy globally.
The pathological end is worth naming plainly. Under sustained high contention the retry loop wastes CPU and I/O proportional to the number of contenders, latency grows because every commit takes several attempts, and — with unbounded retries — a slow writer can be starved indefinitely while faster writers keep winning. Optimistic control does not queue; it thrashes.
occ.attempts_per_commit (5m avg) 1.04 occ.conflict_rate (5m) 3.8% occ.retry_exhausted (5m) 0 per-key breakdown, top 3 by conflict_rate: customer:42 attempts/commit 1.01 conflicts 0.4% counter:global_signups attempts/commit 11.60 conflicts 91.4% <-- hot key inventory:sku-8891 attempts/commit 2.30 conflicts 56.5% p99 update latency: overall 42ms | counter:global_signups 610ms cpu attributable to discarded attempts: ~18% of the update path read: the strategy is correct everywhere and appropriate almost everywhere. one row is a queue pretending to be a bet.
Key points
- Optimistic control holds nothing between read and write; correctness comes from validating the version at commit time.
- The version predicate does not prevent conflicts — it makes them detectable, which is what lets the loser recover instead of silently clobbering.
- The retry loop is part of the mechanism, not an implementation detail: bound it, jitter it, and recompute from a fresh read.
- Retry storms are the characteristic failure. Under high contention the retries become the load and throughput collapses rather than degrading.
- Attempts per successful commit is the metric that decides whether this strategy still fits — and it must be measured per key, not in aggregate.
The loop, answered
Every field is required, which is why no lesson here can recommend concurrency without naming the interleaving that breaks it, the complexity it adds, and the simpler thing to consider first.
- • Read the current value together with a version token — a counter, a timestamp, a row hash, or an ETag.
- • Compute the new value with nothing held. This step may take milliseconds or minutes; the design does not care.
- • Attempt the write conditionally on the version being unchanged: a WHERE clause, a compare-and-swap, an If-Match header.
- • The store reports whether the condition held — one row affected or zero, CAS true or false, 200 or 412.
- • On failure choose a policy: re-read and retry with backoff, merge the two changes, or surface the conflict to whoever made the request.
- • On success the version advances, which is what makes every older in-flight computation detectably stale.
- • No conflict: A reads v5, A writes with version=5 predicate, succeeds at v6; B reads v6, writes with version=6, succeeds at v7. Both win, zero coordination cost.
- • Conflict detected: A reads v5, B reads v5, A commits v6, B's conditional write matches zero rows — B knows it lost and re-reads.
- • Lost update (predicate missing): A reads v5, B reads v5, A writes tier=gold, B writes the whole record from its v5 snapshot — tier silently reverts to silver and both callers saw success.
- • Retry storm: forty writers read v5; one commits; thirty-nine fail, all retry immediately, thirty-eight fail again — commit rate is one per round trip while CPU and database load scale with the contender count.
- • Starvation: a writer with a long compute step re-reads, recomputes, and by the time it attempts the commit the version has moved again — under unbounded retries it can loop indefinitely while short writers keep winning.
- • ABA at the value level: A reads balance=100, B sets it to 0 and back to 100; A's value-based check passes although the state it reasoned about did not persist. This is why the token is a version, not the value. See The ABA Problem: The Value Came Back.
- • Guarantees that a write derived from a stale version is rejected rather than applied — the lost update becomes a detected conflict.
- • Guarantees no lock is held across the think time, so a slow client cannot block anybody else.
- • Does NOT guarantee the write succeeds. Failure is a normal, expected outcome that the caller must handle.
- • Does NOT guarantee progress. Without bounded retries a contended writer can be starved indefinitely; this is livelock-shaped, not deadlock-shaped.
- • Does NOT guarantee anything across multiple rows or objects unless they share one version token or one transaction — per-row optimism gives you per-row atomicity only.
- • Does NOT guarantee the retry is semantically correct. Re-applying an intent to a changed world is a business decision the mechanism cannot make.
- • Does NOT protect against value-level ABA if the check compares the value instead of a monotonic version.
- • Zero contention while thinking: the read-to-write window holds no resource, which is exactly why it scales at low conflict rates.
- • Contention reappears at commit time as wasted work rather than as waiting — losers pay their full compute and I/O cost and discard it.
- • Cost per conflict is the whole attempt: the read, the computation and the failed write round trip, multiplied by the number of contenders.
- • Hot keys concentrate it viciously. A single counter row can be at 90% conflict rate while the aggregate looks healthy.
- • The load is self-amplifying: more contention causes more retries, which cause more contention. See Thundering Herd for the same shape at the cache layer.
- • Lost update when the version predicate is omitted or written against the wrong column — the exact failure the mechanism exists to prevent.
- • Retry storm: unbounded, un-jittered retries turning contention into a positive feedback loop and collapsing throughput.
- • Starvation of a slow writer that never wins a race against faster ones.
- • Wasted work amplification — CPU and database load scaling with contenders while commit rate stays flat.
- • Semantic corruption from blind retries that re-apply an intent that no longer makes sense against the new state.
- • ABA when the precondition is the value rather than a version.
- • Silent partial application when a logical change spans two objects with independent versions and only one commit succeeds.
- • Low conflict rates — the overwhelmingly common case for per-entity edits, where two writers rarely touch the same row in the same second.
- • Long think times: a human editing a form, or a computation that takes seconds. Holding a lock across that is far worse than occasionally redoing it.
- • Across a network boundary, where holding a lock means holding it across an unreliable link and a client that may never come back. See A Mutex on Server A Does Nothing About Server B.
- • Read-heavy systems where the read path must not be slowed down by writer locks at all.
- • Anywhere the alternative would be a lock held across I/O — one of the most reliable ways to build a convoy.
- • High contention on a single key: a global counter, a shared inventory row, a leaderboard. Every writer collides with every other and the retries dominate.
- • Expensive computations, where discarding an attempt throws away real work and the retry cost is the dominant term.
- • Non-idempotent side effects performed before the commit attempt — an email sent on attempt one is not un-sent by the conflict.
- • Long transactions spanning many objects, where the probability that nothing moved approaches zero.
- • Systems that need predictable latency, because commit latency now depends on how many other writers showed up.
- • Attempts per successful commit, per key and per endpoint. Near 1.0 the bet is paying; above 2 it is not.
- • Conflict rate — the fraction of write attempts rejected by the predicate — with a per-key breakdown, because contention is never uniform.
- • Retry-exhausted count: how often the bounded loop gives up. This is the number your users experience as an error.
- • CPU and database time attributable to discarded attempts, as a share of the write path. Above about a tenth, the retries are the workload.
- • Commit latency distribution, split by attempt number, so you can see the tail that contention creates.
- • Every writer now has two code paths — the happy one and the conflict one — and the conflict path is the one nobody tests.
- • A version token must be threaded through every layer between the store and whatever made the decision, including any UI form and any API response.
- • Retry policy is real design work: attempt bounds, backoff shape, jitter, and which errors are retryable at all.
- • Side effects must be moved after the successful commit, or made idempotent, because attempts can happen more than once.
- • Merge semantics, if you choose merging over retrying, are application logic that has to be written and tested per field.
- • Pessimistic locking on the hot key, which is the correct answer when conflicts are common. See Pessimistic Concurrency and Optimistic vs Pessimistic.
- • Make the operation commutative so conflicts stop existing:
SET count = count + 1needs no version at all, because the database serializes it and the outcome does not depend on what was read. - • Serialize through a single owner — a queue or an actor per entity — so writes to one key are ordered by construction. See The Actor Model.
- • Shard the hot key into N counters summed on read, converting one contended row into N uncontended ones.
- • Let the database's isolation level do it: serializable isolation detects conflicts for you, at its own cost. See Isolation Levels in Database Engineering.
compare_exchange in a loop — retries, and the pointer that lied
do {
old = counter.load(); # 1 read
next = old + 1; # compute off to the side
} while (!counter.compare_exchange(old, next)); # swap only if unchanged| # | T1 — pop() via CAS | T2 — another thread | State |
|---|---|---|---|
| 1 | old ← head (= A) | · | head=A stack=A→B→C |
| 2 | · | pop() → A | head=B stack=B→C |
| 3 | · | pop() → B | head=C stack=C |
| 4 | · | push(A) | head=A stack=A→C |
| 5 | CAS(head, A, B) → SUCCESS | · | head=B stack=B→ freed ✕ head now points at B, which was popped and freed. Node C has vanished from the stack and T1 returned a node it never observed being on top. |
| 6 | return A to the caller | · | head=B stack=corrupt |
Optimistic concurrency lab
Conflict probability grows with the number of writers touching the same item, each conflict costs a full retry, and retries consume the same cores the successful work needs. Real systems add their own effects — backoff, hot keys, transaction size — but the turnover is real and it is why an optimistic scheme that benchmarked beautifully at low load can collapse at high load rather than merely slow down.
What people believe, and what is true
Optimistic concurrency is lock-free, so it is faster.
It holds no lock, which is not the same as being faster. At high contention it does strictly more total work than a lock, because every loser pays full price for a discarded attempt. Lock-free is a progress property, not a performance promise.
A retry loop makes conflicts invisible to the user.
It makes them invisible until the bound is hit or the semantics stop making sense. "Retry until it works" on a field a human typed can silently overwrite a colleague's edit — the mechanism detected the conflict and the policy threw the detection away.
Comparing the value is as good as comparing a version.
It admits ABA. If the value went 100 -> 0 -> 100 while you were thinking, a value check passes and your reasoning about the intermediate state was wrong. Use a monotonic version.
Go deeper
Overview
Read the data and its version. Think as long as you like, holding nothing. Write only if the version has not moved. If it has, you lost the race and must re-read.
Practical
Put the predicate in the same statement as the write so it is atomic. Bound retries, jitter the backoff, recompute from a fresh read, and move every side effect after the successful commit.
Advanced
Measure attempts per commit per key. When a key sits above 2, stop retrying harder: shard it, make the operation commutative, or lock it. Retrying a contended key is the classic wrong response to a correct signal.
Internals
The same shape scales from a CAS instruction on one word to an ETag over HTTP. What changes is the cost of a failed attempt: nanoseconds in the first case, a full network round trip in the last, which is why the acceptable conflict rate differs by orders of magnitude between them.