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

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

A10 · FREE

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

seat_id: A10 · status: FREE

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

seat_id = A10 status = FREE version = 5

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

  1. BEGIN
  2. LOCK SEAT
  3. WAIT FOR PAYMENT…
  4. 3 minutes later…
  5. 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

A10 · HELD

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

Idempotency Row-level locking Short transactions Hold expiry Optimistic / pessimistic CC

  1. Seat selection
  2. Seat hold
  3. Payment
  4. 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.