Optimistic vs Pessimistic Concurrency Control
Pessimistic: lock the data while working on it; nobody else can touch it. Cheap when contention is high, costs a lock acquire on every operation. Optimistic: read freely, do the work, commit only if nothing else changed (CAS, version number, MVCC). Nearly free when contention is low, requires retry under contention. Pick pessimistic when conflicts are common, optimistic when they're rare.
Diagram
The two strategies
Two threads want to update the same data. Pick a strategy (see diagram above):
- Pessimistic. "Assume the other thread will race. Lock first." Take a lock, do the work, release. Lock overhead on every op, even when nobody is contending for it.
- Optimistic. "Assume the other thread won't race. Check at commit." Read the data and a version marker, do the work, commit only if the version is unchanged. Nearly zero cost under no contention; retries pile up under contention.
The names refer to the assumption made at design time, not how the operation behaves at runtime. Pessimistic assumes the worst; optimistic assumes the best.
Where the costs cross
For a single in-process counter, microbenchmarks roughly look like this on modern x86:
| Threads | Pessimistic (mutex) | Optimistic (CAS) | Sharded (LongAdder) |
|---|---|---|---|
| 1 | 30 ns | 5 ns | 8 ns |
| 8 | 500 ns | 50 ns | 12 ns |
| 64 | 2 us (convoy risk) | 10 us (CAS retries) | 15 ns |
Lessons:
- Optimistic wins big at low contention.
- Pessimistic and optimistic both lose at very high contention.
- The real escape from contention is to remove it (sharding, RCU, lock-free MPSC), not to pick the other lock flavor.
How optimistic shows up in three layers
Hardware. Compare-and-swap (LOCK CMPXCHG on x86, LDREX/STREX on ARM). One word, one atomic instruction, succeeds or fails. The basis of all lock-free data structures.
Application. Version columns, ETags, If-Match headers. The application stamps a version on the data; clients send the version they read; the server rejects writes that don't match. This pushes the conflict-handling story into the API.
Database. MVCC plus row locks. Each transaction reads a snapshot (no read locks at all) and writes take row locks at commit time. PostgreSQL, Oracle, MySQL InnoDB. The single biggest lever for read-heavy workloads.
Optimistic in distributed systems
The same pattern shows up everywhere: etcd compare-and-swap, S3 conditional PUT with If-Match, DynamoDB conditional writes, GitHub's compare-and-merge on a PR. All optimistic. The reason is the same: locking across a network is expensive and partitions the whole system on the lock holder's failure. Optimistic just retries.
Anti-patterns
-
Pessimistic lock held across a UI session. Read the row, lock it, send the form to the user, wait 30 seconds for them to come back, then commit. This blocks every other writer for 30 seconds. Use optimistic with a version column instead: the user holds nothing while they think.
-
Optimistic on a hot row. A version column on the "global page view counter" row will fail more often than it succeeds. Either shard the counter (write to one of N rows, sum on read) or accept that it needs a different model (in-memory atomic, separate write-only sink).
-
No retry on optimistic failure. Code that surfaces a 409 every time the version mismatched, even when an automatic retry would succeed, makes the user fix the race. Retry once or twice with backoff before bothering them.
-
Locking with the wrong granularity. A pessimistic table lock when a row lock would do. A row lock when an optimistic version would do. Granularity is its own dial; pick the smallest unit that protects the invariant.
How to pick
Three questions in order:
- What's the actual conflict rate? Measure it on a representative load. Without measurement, estimate from "how often do two users touch the same data in the same window."
- How expensive is a retry? Optimistic with a 1-second retry budget is fine; with a 10-minute retry budget (because the work is expensive), pessimistic is the right pick.
- How tolerant is the workflow of conflict surfaces? An auto-save can swallow conflicts; a money transfer cannot. Optimistic is great when the UX can show "someone else changed this, here's what they did, want to merge or overwrite?"; pessimistic is appropriate when "the first to wait wins" is the right semantic.
If conflict rate is low and retries are cheap and surfaces are tolerable: optimistic. If conflict rate is high or retries are expensive or serialization is required for safety: pessimistic. If conflict rate is brutally high: don't lock at all, redesign to remove the contention.
Primitives by language
- synchronized, ReentrantLock (pessimistic)
- AtomicReference.compareAndSet, AtomicInteger.compareAndSet (optimistic)
- StampedLock.tryOptimisticRead (explicit optimistic mode)
- @Version JPA annotation (DB-row optimistic locking)
Implementations
Lock, increment, unlock. Every operation pays the lock acquire/release cost (typically 20-50ns uncontended, several microseconds contended). Cost is constant per call regardless of how many other threads exist.
1 class PessimisticCounter {
2 private final Object lock = new Object();
3 private long value = 0;
4
5 long incrementAndGet() {
6 synchronized (lock) {
7 return ++value;
8 }
9 }
10 }
11
12 // 1 thread: ~30ns / op
13 // 8 threads: ~500ns / op (lock contention)
14 // 64 threads: ~2us / op (heavy contention, possible convoy)Read the value, compute the new value, compareAndSet. If another thread changed it in between, retry. No locks. Under low contention this is faster than the pessimistic version (single CAS instead of lock acquire). Under heavy contention, the retries dominate and CAS can be slower than a good unfair lock.
1 import java.util.concurrent.atomic.AtomicLong;
2
3 class OptimisticCounter {
4 private final AtomicLong value = new AtomicLong(0);
5
6 long incrementAndGet() {
7 long current, next;
8 do {
9 current = value.get();
10 next = current + 1;
11 } while (!value.compareAndSet(current, next));
12 return next;
13 // Or just: return value.incrementAndGet(); (built in, same idea)
14 }
15 }
16
17 // 1 thread: ~5ns / op (one CAS)
18 // 8 threads: ~50ns / op (some retries)
19 // 64 threads: ~10us / op (cache-line ping-pong, lots of retries) -> use LongAdder insteadStampedLock has three modes: write (exclusive), read (shared), and optimistic read (a stamp + a re-check). Optimistic read takes no lock at all; read a stamp, do the reads, then validate the stamp. If validation fails, fall back to a real read lock. Faster than ReadWriteLock when contention is low and reads dominate.
1 import java.util.concurrent.locks.StampedLock;
2
3 class Point {
4 private double x, y;
5 private final StampedLock lock = new StampedLock();
6
7 double distanceFromOrigin() {
8 long stamp = lock.tryOptimisticRead(); // no lock taken
9 double cx = x, cy = y;
10 if (!lock.validate(stamp)) { // someone wrote during the read?
11 stamp = lock.readLock(); // fall back to real lock
12 try { cx = x; cy = y; } finally { lock.unlockRead(stamp); }
13 }
14 return Math.sqrt(cx * cx + cy * cy);
15 }
16
17 void move(double dx, double dy) {
18 long stamp = lock.writeLock();
19 try { x += dx; y += dy; } finally { lock.unlockWrite(stamp); }
20 }
21 }Key points
- •Pessimistic = 'assume conflict, prevent it' (locks). Optimistic = 'assume no conflict, detect it' (CAS, version checks).
- •Pessimistic cost is constant per operation regardless of contention; optimistic cost is near-zero under low contention but grows (retries) under high contention.
- •Optimistic concurrency uses one of three mechanisms: CAS on a memory word, a version column / ETag in a row, or MVCC snapshots in a database.
- •The break-even point is the conflict rate. Roughly: under 10% conflict, optimistic wins. Above 50% conflict, pessimistic wins. In between, measure.
- •Optimistic doesn't avoid contention, it relocates it: instead of waiting for the lock, the caller waits for a successful retry. The work done before the failed commit is wasted.
- •Hybrid systems pick per-operation: PostgreSQL's MVCC is optimistic for reads (snapshots) and pessimistic for writes (row locks). HTTP If-Match uses optimistic versioning at the protocol layer.
Follow-up questions
▸How does one decide which to use?
▸What about livelock with optimistic?
▸Why does MVCC fit here?
▸How does this map to distributed systems?
▸Are these always exclusive?
Gotchas
- !Optimistic without retry: a single CAS failure becomes a user-visible error. Always handle the false return, either by retrying or surfacing a conflict.
- !Pessimistic + user think time: holding a row lock across a UI session is a deadlock factory. Move locks to commit time (optimistic) or shrink the lock window.
- !Mixing optimistic and pessimistic on the same data without coordination: an optimistic writer can win the race against a pessimistic reader's expected ordering.
- !Versioning a row but never bumping the version on every write path: a code path that forgets to increment version silently breaks optimistic checks.
- !Counting CAS failure as a 'lock' in profiles: it isn't; millions of failed CAS in an atomic counter means contention to fix, not lock-elimination work to do.
Common pitfalls
- Defaulting to pessimistic 'because it's safer.' Pessimistic locks held during long-running work cause cascading stalls and hide the contention; optimistic surfaces it.
- Defaulting to optimistic 'because it's faster.' Under heavy real conflict, optimistic burns CPU on retries and produces worse latency than a quick lock would.
- Using optimistic on a row that two users edit a lot. The retries will burn through the error budget; either resolve the conflict in the model (CRDT, last-writer-wins-with-timestamp) or accept that this is a pessimistic-lock case.
Practice problems
APIs worth memorising
- java.util.concurrent.atomic.AtomicReference (CAS-based optimistic)
- java.util.concurrent.locks.StampedLock (optimistic read mode)
- jakarta.persistence.Version (JPA optimistic locking; javax.persistence.Version for older Java EE projects)
- PostgreSQL: SELECT ... FOR UPDATE / FOR UPDATE NOWAIT / FOR UPDATE SKIP LOCKED
- etcd Txn (compare and swap on key + version)
JPA / Hibernate @Version is the standard optimistic locking for app-server CRUD. SELECT FOR UPDATE is the standard pessimistic. Most distributed coordination is optimistic (etcd compare-and-swap, S3 conditional writes with If-Match). Most local concurrency primitives in libraries are pessimistic (mutex, semaphore). The lock-free / wait-free literature is the optimistic extreme; classical OS textbook locks are the pessimistic extreme.