Systems notebook · concurrency
How Do You Stop Two People From Booking the Same Seat?
An interactive walkthrough of concurrency, row-level locking, seat holds, payment, and idempotent booking APIs — the BookMyShow-shaped problem without the marketing deck.
1. You select A10. Someone else selects A10.
Both press Book. Who gets the seat?
Theatre booking looks like a UI problem. It is mostly a concurrency problem: many clients, one scarce row, money in the middle.
Seat map
Click a seat. States you’ll see later: FREE · HELD · BOOKED · LOCKED
Selected: —
2. The naive implementation
Read free. Write booked. No transaction wrapping both. Under concurrency, that is how you invent extra inventory.
Race: User A vs User B
Without mutual exclusion around check+update, both transactions can observe FREE and both can commit BOOKED.
3. Why the race happens
Checking availability and changing availability are two operations. Unless one protected transaction owns both, another request can slip into the gap.
The CHECK → UPDATE gap
4. Pessimistic locking
Lock the row before you decide. In PostgreSQL and MySQL/InnoDB, a common tool is
SELECT … FOR UPDATE: it acquires row-level locks on the
selected rows inside the current transaction — not a whole-database lock.
BEGIN;
SELECT status
FROM seats
WHERE show_id = $1 AND seat_number = 'A10'
FOR UPDATE;
-- only one transaction holds A10 here
UPDATE seats
SET status = 'BOOKED'
WHERE show_id = $1 AND seat_number = 'A10';
COMMIT;
Lock → wait → discover
B does not “also see FREE.” B waits, then observes the committed state.
5. Row lock vs database lock
Locking A10 should not freeze the theatre. Row-level locking lets another transaction work on A11, A12, B10 while A10 is busy.
Lock only A10
Click A10 to toggle a lock. Other seats should keep responding.
6. Optimistic locking
Instead of waiting, detect conflict. Each seat carries a version.
Updates succeed only if the version you read is still current.
UPDATE seats
SET status = 'BOOKED', version = version + 1
WHERE seat_number = 'A10' AND version = 5;
-- 1 row updated → you won
-- 0 rows updated → someone else committed first
Pessimistic vs optimistic
Neither approach is universally “better.” Pessimistic waits under contention; optimistic retries under contention. Hot seats often prefer short pessimistic critical sections.
7. Don’t hold database locks during payment
Payment can take seconds to minutes. A row lock that spans payment becomes a throughput bug dressed as correctness.
Bad design vs good design
Bad
- BEGIN
- LOCK SEAT
- WAIT FOR PAYMENT…
- 3 minutes later…
- COMMIT
Row remains locked the whole time.
Good
Tx1: lock → verify FREE → SET HOLD + expiry → COMMIT
Payment: outside the DB transaction
Tx2: lock → verify HOLD → SET BOOKED → COMMIT
8. Seat hold
A hold is a temporary reservation with an expiry — application state — not a multi-minute database lock.
Hold expiry
00:08
hold_id: H123
9. API design
Two short write paths beat one long “book everything” request.
Hold → pay → book
POST /seat-holds
{
"show_id": "show_123",
"seat_ids": ["A10", "A11"]
}
→ {
"hold_id": "hold_456",
"status": "HELD",
"expires_at": "2026-09-10T17:04:32Z"
}
Payment
Client pays against hold_456. No seat-row lock is held open during this.
{
"payment_id": "payment_789",
"status": "CAPTURED"
}
POST /bookings
{
"hold_id": "hold_456",
"payment_id": "payment_789"
}
→ {
"booking_id": "book_999",
"status": "CONFIRMED"
}
10. Idempotency
Timeouts happen after the server already succeeded. Retries without an idempotency key create duplicate bookings — a different bug from seat races.
Concurrency control
Can two users modify the same seat at the same time?
Idempotency
What happens when the same client sends the same request twice?
Retry after a lost response
Bookings created: 0
Idempotency protects against duplicate requests. Row locking protects against concurrent seat modification.
11. Database schema
Enough tables to tell the truth. Not a warehouse diagram.
seats
- id
- show_id
- seat_number
- status
- hold_id
- hold_expires_at
- version
seat_holds
- id
- user_id
- show_id
- seat_ids
- expires_at
- status
bookings
- id
- user_id
- show_id
- hold_id
- payment_id
- status
- created_at
payments
- id
- hold_id
- amount
- status
- provider_ref
idempotency_keys
- key
- user_id
- request_hash
- response
- status
- created_at
shows
- id
- title
- starts_at
- venue_id
12. Complete system
Click a component to see what it owns.
Architecture
- Seat selection
- Seat hold
- Payment
- Booking confirmation
13. Cheat sheet
- Race condition
- Concurrent requests observe the same state and both act on it.
- Pessimistic locking
- Lock rows before modifying them.
- SELECT FOR UPDATE
- Row-level lock inside a transaction (e.g. PostgreSQL, MySQL/InnoDB).
- Seat hold
- Temporary reservation with expiry — not a long DB lock.
- Idempotency
- Repeated identical request yields the same result.
- Transaction
- Make critical state transitions atomic.
- Payment
- Happens outside the seat-row lock window.
- Booking confirmation
- Lock + validate hold + mark booked atomically.
Concurrency control and idempotency solve different problems. One is about two actors fighting over one seat. The other is about one actor accidentally asking twice.