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.
Lost update · RMW gap.
Conditional write first.
Pessimistic · OCC · SERIALIZABLE.
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.
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.
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.
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;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
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.
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.
- Redis + TTL —
SET key NX EX secondsatomically 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 columns —
reserved_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
| Rung | In SQL | Elsewhere |
|---|---|---|
| Conditional write | WHERE on the write | DynamoDB ConditionExpression, Redis SET NX, Cassandra LWT, HTTP If-Match |
| OCC | version + WHERE version = … | ETags, etcd revision, DynamoDB version attr |
| Pessimistic | SELECT … FOR UPDATE | mutex / distributed lock while deciding |
| SERIALIZABLE | ISOLATION LEVEL SERIALIZABLE | mostly relational — elsewhere fold onto one cell |
| Distributed lock | reservation + TTL | Redis SET NX EX, ZK/etcd lease |
Choosing the right approach
| Approach | Use when | Avoid when | Complexity |
|---|---|---|---|
| Conditional write | Predicate on the row (counter, status, claim) | App logic or other rows needed | Low |
| Pessimistic | Read-decide-write; high contention | Low contention / conditional already works | Low |
| OCC | Same shape, rare collisions | Retries pile up | Medium |
| SERIALIZABLE | Write skew / no shared row | Hot high-contention paths | Medium |
| Distributed lock | Hold spans wait / external call / steps | Single-row guard in one txn suffices | Medium |
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_requestwith 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.
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.
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:
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.
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.