Design a ticket booking system without double booking

Two people must never buy the same seat. We design seat holds, locking and payment timeouts for a booking system.

A seat map with one seat being held

Goal: users pick seats for a show or flight, hold them briefly, pay, and receive tickets. A seat is sold at most once.

Requirements

  • Browse events and see which seats are free.
  • Select seats and hold them while the user pays (for example 10 minutes).
  • Never sell one seat twice, even when thousands of people click at once.
  • Release seats if payment fails or time runs out.

The hard part: contention

For a popular event, many users want the same seats at the same moment. Correctness comes first, so the seat state must be changed atomically.

Seat states

AVAILABLE -> HELD (until time T) -> SOLD, and HELD -> AVAILABLE when the hold expires or payment fails.

Preventing double booking

Pick one safe method:

  • Conditional update: UPDATE seats SET status='HELD', held_by=?, hold_until=? WHERE seat_id=? AND (status='AVAILABLE' OR hold_until < now()). If it changes one row, you got the seat. If zero, someone else did. This is simple and correct in one statement.
  • Optimistic locking: keep a version column and update only when the version matches.
  • Row lock (SELECT ... FOR UPDATE): safe, but holds locks longer.
  • Redis SET key NX EX for holds, with the database as the final source of truth.

A unique constraint on (event_id, seat_id) in the tickets table is a final safety net.

Booking flow

  1. User selects seats. The server tries to hold all of them in one transaction (all or nothing).
  2. The user pays within the hold time.
  3. Payment success: mark seats SOLD and create tickets. This step is idempotent so a retried callback does nothing twice.
  4. Failure or timeout: a background job (or lazy expiry check) frees the seats.

Payment races

A payment may succeed just after the hold expired. On success, re-check the hold in the same transaction. If the seat is gone, refund automatically and tell the user.

Handling the rush

  • Virtual waiting room: admit users in batches so the booking path is not flooded.
  • Serve the seat map from a cache, and verify availability at hold time.
  • Rate-limit and block bots.
  • Scale reads freely, but keep the write path to the seat table small and fast.

Scaling

Shard by event, since seats of different events do not interact. The one hot event is the only real problem, and a waiting room plus fast conditional updates handles it.

Takeaway: use an atomic conditional update for seat holds, expire holds automatically, make payment confirmation idempotent, and protect the system with a waiting room.


System Design series, Part 3 (case studies): 11 of 16. Previous: Design file storage and sync like Google Drive.

Keep reading