Contention pattern — conditional writes, locks, optimistic concurrency, distributed leases

Dealing with contention for system design interviews

Race conditions, lost updates, and the coordination ladder: conditional writes, pessimistic locking, optimistic concurrency, isolation levels / write skew, distributed locks, and when to serialize — with interview scenarios and deep dives.

Why contention shows up everywhere

Booking the last concert seat or bidding on an auction item looks simple until two buyers hit "Buy Now" in the same instant. The naive path — read the count, check it's above zero, decrement — works for one buyer and fails for two.

This post is the deep dive behind the short ladder in Common patterns. Pair with Consistency models, PostgreSQL, and Redis.

01Race

Lost update · RMW gap.

02CAS

Conditional write first.

03Locks

Pessimistic · OCC · SERIALIZABLE.

04Leases

Distributed holds · hot keys.

The race condition

There's one seat left for The Weeknd. Alice and Bob both hit Buy Now. The obvious purchase path:

-- Read the current count
SELECT available_seats FROM concerts WHERE concert_id = 'weeknd_tour';

-- App checks available_seats > 0, then writes back
UPDATE concerts
SET available_seats = available_seats - 1
WHERE concert_id = 'weeknd_tour';

For a single buyer this is exactly right. Concurrently, both read 1, both pass the check, both charge a card. Alice commits to 0; Bob decrements again to -1. Two confirmations, one seat — refunds and angry customers.

Alice and Bob both read one seat available, pay, and decrement the count on overlapping timelines.
Both read the same stale count, both pay — the gap between read and write is where the race lives.

The real culprit is treating read + write as one action when they aren't. The window is tiny — microseconds in memory, milliseconds over a network — but enough for both to "win." At 10,000 concurrent users, small windows become massive conflict rates. Scale across nodes and you need even more coordination.

The solution ladder

Every fix below closes the gap between reading a value and acting on it. Underneath all of them is the same move: compare-and-set — read a value and make your write conditional on it not having changed. Examples in SQL; the same moves live in any serious datastore. Start simplest; add coordination only as each approach hits its limit.

Five-step ladder from conditional write to distributed lock.
Walk down the list — take the first tool that fits the shape of the write.

Conditional writes

Start here. A lot of safety rules boil down to a simple if: decrement only if a seat is left; mark shipped only if not cancelled. When the rule is a predicate on current data, the database can check and change in one statement — no locks or version numbers.

UPDATE concerts
SET available_seats = available_seats - 1
WHERE concert_id = 'weeknd_tour'
  AND available_seats > 0;

Under concurrency this is already safe. The database won't let two updates change the same row at once — they take turns. Alice goes 1→0; Bob waits, re-checks available_seats > 0, matches zero rows. One seat, one sale. Check and decrement are a single atomic step.

Alice's conditional update wins; Bob matches zero rows.
The WHERE clause is how SQL spells compare-and-set.

Transactions for multi-write purchases

A real purchase also inserts a ticket row. Decrement but fail the insert and you've taken a seat for a ticket that doesn't exist. Wrap in BEGIN/COMMIT.

Trap: an UPDATE matching zero rows is not an error — the statement succeeds and won't roll back the transaction. Insert unconditionally and Bob still gets a ticket. Gate the insert on whether the update changed anything:

BEGIN TRANSACTION;

WITH reservation AS (
  UPDATE concerts
  SET available_seats = available_seats - 1
  WHERE concert_id = 'weeknd_tour'
    AND available_seats > 0
  RETURNING concert_id
)
INSERT INTO tickets (user_id, concert_id, seat_number, purchase_time)
SELECT 'user123', concert_id, 'A15', NOW()
FROM reservation;

COMMIT;

Or check affected row count in the app and roll back when it's zero.

UPDATE tickets
SET status = 'sold', user_id = 'user123'
WHERE concert_id = 'weeknd_tour'
  AND seat_number = 'A15'
  AND status = 'available';

Now one flips to sold; the other matches zero rows. Seat count becomes derived (or a cache decremented in the same transaction).

Pessimistic locking

A group of four wants contiguous seats. Your app must read the map, find a block, then claim. Finding a contiguous block isn't a WHERE predicate — read and write can't collapse into one statement. Two groups both land on A15–A18; lost update returns.

Pessimistic locking acquires locks upfront — pessimistic that conflicts will happen.

BEGIN TRANSACTION;

SELECT seat_number FROM seats
WHERE concert_id = 'weeknd_tour'
  AND section = 'floor'
  AND status = 'available'
FOR UPDATE;

-- App finds A15–A18 adjacent, then:
UPDATE seats
SET status = 'sold', user_id = 'user123'
WHERE concert_id = 'weeknd_tour'
  AND seat_number IN ('A15', 'A16', 'A17', 'A18');

COMMIT;
Group A holds FOR UPDATE while deciding; Group B waits.
Lock what you read, decide while held still, write before releasing.
Three threads take turns holding a lock on the same database row over time.
Explicit row locks: one holder at a time; the lock badge tracks the owner.

FOR UPDATE locks every returned row. Group B waits until you commit; by then A15–A18 are sold. Same shape for any read-decide-write that isn't a predicate — balance vs daily limit, stock across a bundle. If logic simplifies back to a WHERE, drop the lock.

Common failure modes

  • Locking too much, too long — table locks serialize everyone; multi-second holds (especially payment I/O inside the txn) pile buyers into a bottleneck. Do slow I/O before or after the lock. Scope to open seats in one section; release on claim.
  • Deadlocks from inconsistent order — two txns grab the same rows opposite ways. Always acquire in sorted ID order. Treat deadlock errors as retryable.

Optimistic concurrency control

OCC assumes conflicts are rare and detects them at write time instead of blocking. Same gap, no held lock. Under low contention, skip locking overhead.

Keep a value that changes on every write (usually a version integer). Read it; write only if it still matches. Zero rows → someone beat you → retry or fail clearly. Same idea as HTTP ETags / If-Match, etcd revisions, DynamoDB version attributes.

-- Both Alice and Bob read: 1 seat, version 42

-- Alice:
BEGIN;
UPDATE concerts
SET available_seats = available_seats - 1, version = version + 1
WHERE concert_id = 'weeknd_tour' AND version = 42;
INSERT INTO tickets (...) VALUES (...);
COMMIT;  -- seats=0, version=43

-- Bob (stale version=42):
UPDATE ... WHERE version = 42;  -- 0 rows
-- Check count, ROLLBACK, skip insert
Alice bumps version 42 to 43; Bob's WHERE version=42 matches nothing.
Check affected row count — zero rows is not an automatic rollback.

Alternatives to a dedicated column: last-updated timestamp (watch clock resolution), or a business value that only moves one way (high bid). Watch the ABA problem if a value can go A→B→A — dedicated incrementing versions are the safe default.

Isolation levels & write skew

Sometimes two transactions each read overlapping rows, each decision is valid alone, and together they break a rule — and no single row collides. Conditional UPDATE, row lock, and version check all sail through. That's write skew.

On-call rule: ≥1 engineer always on call. Alice and Bob both active; both try to step down. Each sees the other still on call, both remove themselves, now zero on call.

Alice and Bob each remove themselves from on-call after seeing the other active.
No shared row to lock — conflict lives in what they read to decide.
Read Committed shows overlapping transactions; Serializable shows them end to end.
Read Committed allows concurrent overlap; Serializable guarantees an equivalent serial order.
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;

SELECT count(*) FROM on_call
WHERE team_id = 'payments' AND is_active = true;

UPDATE on_call SET is_active = false WHERE engineer_id = 'alice';

COMMIT;
-- Concurrent Bob may abort one txn with a serialization error → retry

Same lesson as seats: the database can only protect a conflict it can see as one addressable cell.

Distributed locks

Everything so far lives inside one DB transaction — and a DB lock lasts only as long as that transaction. Ticketmaster holds seat A15 for ten minutes while you pay. FOR UPDATE can't: it pins a connection, stalls others, and other web servers can't see it.

Hold exclusivity as data — who holds the seat and when the hold expires — that any server can read. That's a distributed lock / lease.

User through web servers to Redis or reservation columns holding seat A15 with TTL.
Shrink the fight window from whole checkout to the instant of selection.
  • Redis + TTLSET key NX EX seconds atomically creates a lock Redis clears on expiry. Fast; any server can check. Catch: if holder stalls past TTL (GC pause), two clients can briefly think they own it — OK for soft seat holds, not for corrupting money. Plan Redis as a SPOF.
  • Database columnsreserved_by + reserved_until; claim with the same conditional write if free or expired. No new infra; slower under hot load.
  • ZooKeeper / etcd — purpose-built coordination, ephemeral nodes / leases, consensus. Robust under partitions; operational cost of a cluster.
UPDATE seats
SET reserved_by = 'user123', reserved_until = NOW() + INTERVAL '10 minutes'
WHERE seat_id = 'A15'
  AND (reserved_until IS NULL OR reserved_until < NOW());
-- 1 row = you got the hold; 0 = someone else has it

Same moves across stores

RungIn SQLElsewhere
Conditional writeWHERE on the writeDynamoDB ConditionExpression, Redis SET NX, Cassandra LWT, HTTP If-Match
OCCversion + WHERE version = …ETags, etcd revision, DynamoDB version attr
PessimisticSELECT … FOR UPDATEmutex / distributed lock while deciding
SERIALIZABLEISOLATION LEVEL SERIALIZABLEmostly relational — elsewhere fold onto one cell
Distributed lockreservation + TTLRedis SET NX EX, ZK/etcd lease

Choosing the right approach

ApproachUse whenAvoid whenComplexity
Conditional writePredicate on the row (counter, status, claim)App logic or other rows neededLow
PessimisticRead-decide-write; high contentionLow contention / conditional already worksLow
OCCSame shape, rare collisionsRetries pile upMedium
SERIALIZABLEWrite skew / no shared rowHot high-contention pathsMedium
Distributed lockHold spans wait / external call / stepsSingle-row guard in one txn sufficesMedium

When to use in interviews

Don't wait to be asked. When multiple processes compete for a scarce resource — and NFRs demand strong consistency — call contention out and name the mechanism.

Recognition signals

  • Limited resources: tickets, auction items, flash inventory, driver↔rider matching
  • Prevent double-booking / double-charging: payments, seats, meeting rooms
  • Consistency under concurrency: balances, inventory, collaborative edits
  • Same sensitive op across servers where order matters

Common scenarios

  • Online auction — OCC with high bid as version (bids only go up → no ABA).
  • Ticketmaster — temporary reservations with TTL beat bare FOR UPDATE for UX; hold 10 minutes while paying.
  • Banking — single DB → pessimistic/OCC here; cross-service transfer → multi-step / saga.
  • Ride dispatch — driver pending_request with TTL so two riders don't get the same driver.
  • Flash sale — OCC on inventory + cart holds (distributed TTL) to reduce checkout contention.
  • Yelp ratings — OCC with dedicated version when concurrent reviews update averages.

Common deep dives

How do you prevent deadlocks with pessimistic locking?

Alice transfers to Bob while Bob transfers to Alice — each locks their own account first, then waits forever on the other.

Transaction 1 holds Alice and is blocked on Bob; Transaction 2 holds Bob and is blocked on Alice.
Deadlock: circular wait — always lock by sorted ID, then retry on abort.

Ordered locking: sort participants by deterministic key before acquiring any lock. Alice(456)→Bob(123) still locks 123 first. Don't "lock initiator first." Databases detect cycles and abort one txn — catch and retry. Lock-wait timeouts catch stuck holds, not only true cycles.

How do you handle the ABA problem with OCC?

A value goes A→B→A between your read and write. Equality on the business field passes and you clobber a meaningful change.

Count goes 100 to 99 to 100; WHERE=100 wrongly succeeds.
Fix: dedicated version that only increments — or put every read field in WHERE.
UPDATE restaurants
SET avg_rating = 4.1, review_count = review_count + 1, version = version + 1
WHERE restaurant_id = 'pizza_palace'
  AND version = 42;

What about performance when everyone wants the same resource?

Hot partition / celebrity problem: sharding and load balancing don't help when everyone writes the same row. First question whether you can change the problem (10 identical auction lots; eventual follower counts). If you need strong consistency on that hot key:

Client to server to message queue to worker to database.
Client → Server → Queue → Worker → DB — same-partition writes stay ordered.

Queue-based serialization: one worker per hot resource. Buffer spikes; eliminate concurrent conflict. Tradeoff: throughput ceiling and a SPOF you'd back with a standby.

In your interview

What to say out loud

"I'll keep the contended resource in one primary. If the check is a predicate I'll use a conditional update and gate follow-up writes on affected rows. If I need app logic between read and write I'll use FOR UPDATE under high contention, or a version column under low contention. Write skew that spans rows gets SERIALIZABLE or a materialized counter. Seat holds across checkout get a TTL lease — Redis or reservation columns — not a ten-minute FOR UPDATE. Deadlocks: sorted lock order + retry. Hot celebrities: queue serialize or redesign the problem."

Contention clinic: BookMyShow last seat

Cost and performance levers

Interview Q&A by level

Practice saying these out loud for contention / concurrency. Interviewers grade clarity and judgment more than buzzwords.

Interview takeaway

Match depth to the bar: define → trade off → operate. Don't dump principal answers in an entry-level screen.

Wrapping up

Contention handling is simpler than it looks: every contended resource has a single source of truth, and correctness is enforced there. Conditional writes, locks, isolation, and OCC coordinate access at that home; distributed leases stretch exclusivity past one transaction. Protect access to the source of truth — don't replace it.

Pessimistic for predictable high contention; optimistic when conflicts are rare. Postgres can absorb more single-home contention than candidates assume. Reach for external locks when traffic or UX demands it, not by default. The moment an op must span multiple sources of truth, you've left this pattern for multi-step / distributed transactions.

Continue with Common patterns, Consistency models, PostgreSQL, Redis, ZooKeeper, Design Gopuff, Design Ticketmaster, Design Tinder, and Rate limiter.

← Lattice