Stopping two people from booking the same slot
Open in the interactive app: practice, quiz, mock interview →In one minute
- Double booking is a race: two requests both read "available" and both write "booked". Any design that checks and then writes in separate steps without coordination has this bug.
- The fix is to make the check and the write one atomic step at the point of truth: a conditional update, a unique constraint, a row lock, or an exclusion constraint in the database.
- Real booking flows add a short hold (reservation with expiry) between "pick a seat" and "pay", so users are not charged for seats they lose, and abandoned carts release inventory automatically.
- Distributed locks in Redis are a performance tool, not a correctness tool. The database constraint must still reject a second booking even if a lock fails.
- Under extreme contention (a concert on-sale), the bottleneck is the hot rows, so strong answers add admission control, a virtual waiting room, or a queue in front of the inventory.
How it works
When the pattern shows up in interviews
Ticketing, hotel and flight booking, meeting-room calendars, ride matching and flash sales all hide one question: how is a scarce unit allocated exactly once? Interviewers often let you draw the happy path first and then ask, "What if two people click the same seat at the same millisecond?"
Option 1: Conditional write (optimistic)
Make the update conditional on the seat's state: UPDATE seats SET status='held', holder=$u, hold_expires=now()+'10 min', version=version+1 WHERE id=$s AND (status='available' OR hold_expires <= now()). One row affected means you won; zero means someone else did. No lock is held between requests. The same idea works with a version column or DynamoDB condition expressions.
Option 2: Unique constraint
Model the booking as an insert into a table with a uniqueness rule, such as UNIQUE(event_id, seat_id) on bookings. The second insert fails with a constraint violation. This is the most robust option because the invariant is declared rather than coded, so every code path, script and future service obeys it. For time ranges (hotel nights, meeting rooms), Postgres offers an exclusion constraint over a range type: EXCLUDE USING gist (room_id WITH =, during WITH &&) rejects any overlapping reservation for the same room. The room_id WITH = part needs the btree_gist extension, because plain scalar types have no GiST operator class without it (PostgreSQL docs). PostgreSQL 18 (September 2025) adds a friendlier spelling of the same rule, a temporal key: PRIMARY KEY (room_id, during WITHOUT OVERLAPS), enforced by a GiST index, and foreign keys can reference it with PERIOD (PostgreSQL 18 release notes, CREATE TABLE).
Option 3: Pessimistic lock
SELECT ... FOR UPDATE locks the row so the transaction can check rules too complex for one statement (price rules, multi-seat adjacency) before updating; competitors block. Never hold it across a payment call or user think time. For "give me any free seat in this section", FOR UPDATE SKIP LOCKED lets concurrent buyers grab different rows instead of queueing on the same one; the docs warn that skipping gives an inconsistent view, fine for "any row" but not for "is this exact seat free?" (PostgreSQL docs).
Do not rely on isolation level alone for a read-then-write check. Jepsen found that MySQL 8.0.34's default REPEATABLE READ allowed lost updates (198 in one test run) and concluded that such transactions cannot safely read a value and then write it; use SELECT ... FOR UPDATE, SERIALIZABLE, or a conditional write (Jepsen).
Holds and two-phase booking
Payment takes seconds and users abandon carts, so real systems split booking into hold and confirm. The hold is a conditional write with an expiry. Confirmation is another conditional write that succeeds only if the hold is still owned by this user and has not expired: UPDATE ... SET status='sold' WHERE id=$s AND holder=$u AND hold_id=$h AND hold_expires > now(). Expiry needs no background job for correctness, because the conditions treat an expired hold as available; a sweeper only tidies up. The payment is authorised during the hold and captured only after confirmation succeeds, and the payment call carries an idempotency key so retries do not charge twice.
Where Redis locks fit
Putting the hold in Redis with SET seat:123 user NX PX 600000 is fast, but a failover can lose a just-acknowledged key, and a client paused by GC can act after its lock expired. Redis's own docs now discourage this single-node lock pattern in favour of Redlock (Redis docs), and Redlock still assumes bounded clock drift and pauses and cannot issue increasing fencing tokens (Kleppmann). Treat Redis as a filter and keep the database condition as the arbiter, or pass a fencing token (increasing per grant) that storage checks.
Handling the stampede
At a big on-sale, even perfect atomic updates serialise on hot rows and most requests fail. Strong answers move contention out of the database: a virtual waiting room admits users at a controlled rate (Cloudflare's, for example, is configured as "total active users" plus "new users per minute", and admits the oldest arrival-minute buckets first (Cloudflare)), seat maps come from a slightly stale cache (the conditional write catches staleness), and inventory is partitioned by section.
Down the stack
Layer 1The contract
Invariant: for each (event, seat), at most one booking in state sold, and at most one live hold at any instant. A hold request returns success to exactly one of N concurrent callers. A confirm succeeds only for the current, unexpired hold. Failures must be safe: a crash at any point may leave a hold that expires, never a seat sold twice or a charge without a seat.
Layer 2The mechanism
In Postgres, an UPDATE finds the row version visible to its snapshot, takes a row-level lock by writing its transaction ID into the tuple's xmax, and creates a new tuple version. A concurrent UPDATE on the same row waits on the first transaction. Under READ COMMITTED, when the first commits, the waiter re-evaluates its WHERE clause against the new version (the "EvalPlanQual" recheck), so it sees status='held' and updates zero rows. Under REPEATABLE READ the waiter instead fails with "could not serialize access due to concurrent update" (SQLSTATE 40001) and must retry the whole transaction (PostgreSQL docs). A unique index is enforced inside the B-tree insert: the second inserter finds a conflicting entry, waits if the first transaction is still in flight, then raises unique_violation if it committed. An exclusion constraint does the same with a GiST index search for overlapping ranges.
Layer 3The machine
Row locks live in the tuple header on the heap page, not in a separate lock table, so locking millions of rows costs no shared memory; waiters block on the holder's transaction ID lock instead. The commit that makes a booking durable is one WAL fsync, and group commit lets many transactions share one flush, which is why a single Postgres primary can commit thousands of small booking transactions per second on fast storage. A hot row serialises: if each holder keeps the lock for 2 ms (one round trip plus commit), that row tops out around 500 updates per second no matter how many cores the server has. A BEGIN, SELECT FOR UPDATE, UPDATE, COMMIT sequence from an app server 0.5 ms away holds the lock for several round trips; one conditional UPDATE holds it far less.
Why it's built this way
- Two independent readers cannot both see each other's future writes, so the check must happen where writes are serialised: the row or index in the database.
- Holding locks across user think time or payment would block everyone else, so the flow splits into a short atomic hold and a later atomic confirm.
- Holders crash and abandon carts, so holds carry an expiry that the conditions themselves enforce, without relying on a cleanup job.
- Hot rows serialise on lock hold time, which is bounded by network round trips and commit latency, so high-demand sales add admission control before the database.
Diagram
stateDiagram-v2
[*] --> Available
Available --> Held: hold (conditional update wins)
Held --> Available: expires or released
Held --> Held: same user refreshes
Held --> Sold: confirm with matching hold id before expiry
Sold --> Available: refund or cancellation
Sold --> [*]Worked example
A stadium sale offers 50,000 seats and expects 400,000 users in the first minute. Without control, that is thousands of hold attempts per second concentrated on the best few thousand seats.
The team puts a waiting room in front: users get a queue position and are admitted at 2,000 per minute, roughly the rate at which held seats either convert or expire. Admitted users see a seat map cached for 2 seconds. Clicking a seat runs the single conditional UPDATE with a 7-minute hold. With 2,000 admitted users each making a few attempts, the database sees perhaps 100 to 200 hold writes per second spread across 50,000 rows, well within one primary. Most conflicts come from the cached map being slightly stale; they cost one failed update and a refresh.
Payment uses the hold ID as its idempotency key. If confirm then updates zero rows because the hold expired during a slow payment, the service voids the authorisation. Holds that expire unpaid return to the pool through the expiry condition alone.
In the wild
- Jepsen, 2020: etcd's locks do not exclude. etcd's key-value operations tested strict-serializable, but its lock API did not give mutual exclusion even on a healthy cluster: with 2-second lease TTLs and process pauses, lock-protected updates lost about 18% of acknowledged writes. Jepsen's advice matches this lesson: use the lock for efficiency and have the protected resource check a fencing token such as the lock key's revision (Jepsen).
- Ticketmaster, 2022: the Eras Tour presale. About 3.5 million fans pre-registered and about 1.5 million received codes, but bots and fans without codes drove 3.5 billion system requests, four times Ticketmaster's previous peak. It slowed some sales and postponed others to stabilise the systems, accepting longer queues; about 15% of interactions still hit errors, including passcode failures that dropped tickets already in carts. Over 2 million tickets sold that day, a single-day record (Ticketmaster, archived).
- Cloudflare, 2021: a waiting room for vaccine bookings. Cloudflare first ran its Waiting Room for COVID-19 vaccination sites through Project Fair Shot. Each edge data centre gets a share of admission slots based on its traffic two minutes earlier, so propagation delay causes only small overshoots; one county site handled surges of up to 23,000 users (Cloudflare).
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| Conditional update | One round trip, no held locks, simple | Rule must fit one statement | Single seat or unit, most cases |
| Unique or exclusion constraint | Invariant declared once, enforced on every path | Needs a modelling fit, errors to translate | Slots, ranges, one-booking-per-key |
SELECT FOR UPDATE | Arbitrary checks in a transaction | Lock wait queues, deadlock risk | Multi-row rules, low contention |
| Redis lock or hold | Very fast, offloads the DB | Not safe alone under failover or pauses | Pre-filter in front of a DB check |
| Waiting room or queue | Smooths spikes, fair ordering | Extra latency and infrastructure | Flash sales, ticket on-sales |
Pitfalls in production
- Check-then-act in application code.
if seat.available: seat.book()passes every test and fails under load. Push the condition into the write. - Holding a transaction across payment. Locks are held for seconds and the pool drains. Use hold and confirm as separate short transactions.
- Expiry enforced only by a cron job. When the job is late, seats stay stuck; when it races with a confirm, a paid seat is released. Put the expiry in the conditions.
- Trusting a Redis lock as the guard. After a failover two clients hold "the" lock. Keep a database constraint or fencing token behind it.
Leadership lens
- Ask, "Where is the invariant enforced?" The answer should be one place in the storage layer, not a convention spread across services.
- Agree the hold duration with product: too short frustrates buyers, too long strands inventory during a sale.
- Plan the on-sale as an event: load test the waiting room, pre-warm caches, and rehearse what happens when payment slows down.
- Reconcile sold seats against captured payments daily and alert on any mismatch.
Check yourself
Two transactions run the conditional hold
UPDATEon the same row underREAD COMMITTED. Why does exactly one succeed?Show answer
The second blocks on the first's row lock, then re-evaluates its
WHEREclause against the committed new version, finds the seat held, and updates zero rows.Why is it safe for a hold to expire without any background job?
Show answer
Both hold and confirm conditions compare
hold_expireswith the current time, so an expired hold is treated as available and cannot be confirmed.A payment succeeds but the confirm updates zero rows. What happened and what should the system do?
Show answer
The hold expired and someone else may have taken the seat; void or refund the payment using its idempotency key and tell the user.
Why can a hot row only take a few hundred updates per second even on a large server?
Show answer
Updates to one row are serialised by its lock, so throughput is bounded by how long each holder keeps the lock, which is set by round trips and commit latency.
Sources and further study
Primary
- PostgreSQL 18 release notes: temporal
PRIMARY KEY/UNIQUE ... WITHOUT OVERLAPSandPERIODforeign keys. - Jepsen: etcd 3.4.3: measured evidence that lease-based locks need fencing at the resource.
Systems docs
- PostgreSQL: CREATE TABLE: exclusion constraints and
WITHOUT OVERLAPSwith their GiST andbtree_gistrequirements. - PostgreSQL: SELECT locking clause:
FOR UPDATE,NOWAITandSKIP LOCKEDsemantics. - Redis: distributed locks with Redis: the Redlock algorithm and the safety arguments Redis makes for it.
Books
- Designing Data-Intensive Applications (1st edition), Martin Kleppmann, chapter 7 "Transactions", section "Weak Isolation Levels": lost updates, write skew and the meeting-room booking example; chapter 8, section "Knowledge, Truth, and Lies", for fencing tokens.
- System Design Interview, Volume 2, Alex Xu and Sahn Lam, chapter 7 "Hotel Reservation": optimistic versus pessimistic locking and database constraints for inventory.
Other curricula
- Azure Architecture Center: Queue-Based Load Leveling pattern: frames the on-sale stampede as a buffering problem, putting a queue in front of the contended store and bounding the consumers' rate, with the caveat that it does not suit callers who need a synchronous answer.
Watch
- System Design Interview: Design Ticketmaster w/ a Ex-Meta Staff Engineer (Hello Interview): a full interview walkthrough including seat holds and the virtual waiting room.
- Pessimistic concurrency control vs Optimistic concurrency control in Database Systems Explained (Hussein Nasser): how the two locking styles behave inside a database.
- "Transactions: myths, surprises and opportunities" by Martin Kleppmann (Strange Loop Conference): what isolation levels actually guarantee and where races slip through.
Free resources
- Explicit locking (PostgreSQL docs): row-level lock modes, deadlocks and advisory locks.
- Transaction isolation (PostgreSQL docs): how
UPDATEre-checks rows under Read Committed and when serialisation errors occur. - Range types and exclusion constraints (PostgreSQL docs): the built-in way to forbid overlapping reservations.
- How to do distributed locking (Martin Kleppmann): why lock leases need fencing tokens to be safe.
- Postgres 9.5 feature highlight: SKIP LOCKED for row-level locking (Michael Paquier): grabbing any free row without queueing on locked ones.