Immutability & Concurrency Control

Optimistic Concurrency Control

Read version 5, compute the change, write only if the row is still at version 5. If it moved, somebody else got there first — retry or fail. No locks held, no blocking, and a failure mode that arrives all at once: retry storms under high contention.

▶ Run the lab

The question this answers

The question

Can I update shared state without holding a lock while I decide what to write?

The work

Two support agents editing the same customer record in a web console, thirty seconds apart in intent and forty milliseconds apart in arrival.

What is shared

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.

The invariant — what must stay true under every interleaving

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.

WorkCan it overlap?Can it parallelise?What is shared?What ordering?What synchronization?Where is contention?What can deadlock?What can race?What is gained?What complexity?

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.

The same two writers, twice: unconditional write, then version-checked write.SIMULATED
Invariant · No committed write is silently overwritten by one computed from an older version.
#Agent A (sets tier=gold)Agent B (sets phone=+49...)State
1SELECT * 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
3UPDATE ... 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 rowsversion=6 tier=gold phone=old
7·re-read v6, recompute (tier=gold, phone=new), UPDATE ... WHERE version=6 -> 1 rowversion=7 tier=gold phone=new
The predicate does not prevent the conflict — it makes the conflict *detectable*, which is the only thing that lets the loser recover. Everything else in optimistic control is a policy question about what the loser should do.

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 = 5
2
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 attempt
6 const next = apply(cur, change) // recompute, never replay
7 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 bet
12
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}
A retry loop that cannot become a storm.

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.
Conflict metrics from a version-checked update path. Aggregate looks fine; one key does not.

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.

How it works
  • 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.
Interleavings that matter
  • 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.
What it guarantees — and does not
  • 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.
Where contention appears
  • 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.
How it fails
  • 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.
When it helps
  • 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.
When it hurts
  • 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.
How you would know
  • 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.
Complexity it introduces
  • 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.
Simpler alternatives
  • 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 + 1 needs 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

compare_exchange in a loop
Read the value, compute a new one, swap it in only if nobody changed it meanwhile — otherwise start over. The loop is lock-free: somebody always makes progress. It is not free: everybody else did the work twice.
do {
    old = counter.load();          # 1 read
    next = old + 1;                # compute off to the side
} while (!counter.compare_exchange(old, next));   # swap only if unchanged
successes
8
CAS attempts
36
wasted retries
28
attempts per success
4.5
Total CAS attempts to complete N increments
1 thread1 · 1 succeed, 0 wasted · 1.0× the work per increment
2 threads3 · 2 succeed, 1 wasted · 1.5× the work per increment
4 threads10 · 4 succeed, 6 wasted · 2.5× the work per increment
8 threads36 · 8 succeed, 28 wasted · 4.5× the work per increment
16 threads136 · 16 succeed, 120 wasted · 8.5× the work per increment
32 threads528 · 32 succeed, 496 wasted · 16.5× the work per increment
CAS succeeds on a stale pointer
Invariant · head points at a live node, and the stack contains exactly the nodes pushed and not yet popped.
#T1 — pop() via CAST2 — another threadState
1old ← head (= A)·head=A stack=A→B→C
2·pop() → Ahead=B stack=B→C
3·pop() → Bhead=C stack=C
4·push(A)head=A stack=A→C
5CAS(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.
6return A to the caller·head=B stack=corrupt
At 8 threads the loop costs 36 attempts for 8 increments — 4.5 attempts each, and the total grows as N²/2 while the useful work grows as N. Half the machine is now computing values that will be thrown away, and every failed attempt still pays for exclusive ownership of the cache line. Turn the guard on and watch the same schedule end differently. Without it, T1 asks "is head still A?" — the only question CAS can ask — and A is indeed back on top. But it is on top of a different stack: B was popped and freed while T1 was looking away, and the CAS happily installs a pointer to reclaimed memory. This is the ABA problem, and it is not a race in the usual sense: nothing was concurrent at the moment of the CAS, the world simply changed and changed back. Lock-free is a progress guarantee — some thread always advances — not a speed guarantee. Under this much contention a plain mutex often wins, because it lets the losers sleep instead of burning cores computing values nobody will keep.
SIMULATEDWorst-case contention: every thread attempts every round and exactly one wins. Real hardware backs off, and cache-line ownership changes the constant — the quadratic shape does not.

Optimistic concurrency lab

Optimistic vs pessimistic under contention
A version-checked update: read the row with its version, compute, write back only if the version is unchanged, retry if it moved.
SIMULATEDThe shape of the curve is the lesson; the axis numbers are not.

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.

optimistic (version check + retry)pessimistic (lock, then work)x: 1–48 · y max 730 commits/s
optimistic
646/s
pessimistic
196/s
attempts per commit
1.24
work thrown away
19%
retry rate (attempts that fail the version check)19.2%
commits landing646.4/s
At 8 writers and 3% contention the optimistic scheme commits 646/s against the lock’s 196/s: 19% of attempts retry, which is cheap enough that never waiting wins. Note where it peaks — 730/s at 4 writers — and that past that point adding writers makes the system slower, not faster.
Crossover at 48 writers: below it, waiting is the waste; above it, retrying is. Neither scheme is “the fast one” — the contention rate decides, and the contention rate is a property of your data, not of your code.
Both schemes preserve the same invariant — no lost update — and both are correct. What differs is where the cost lands: predictable waiting versus unpredictable wasted work, and a retry loop that must be bounded or it becomes a livelock. The version check itself is the contract; API Design and Database Engineering cover how it is exposed and how a store implements it.
optimistic leads at 8 writersSIMULATED

What people believe, and what is true

Claim

Optimistic concurrency is lock-free, so it is faster.

Reality

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.

Claim

A retry loop makes conflicts invisible to the user.

Reality

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.

Claim

Comparing the value is as good as comparing a version.

Reality

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.

Apply it