system-design

Design a Ticketing System (BookMyShow / Ticketmaster)

Difficulty: Tier 2 Asked at: Amazon, BookMyShow, Ticketmaster, Careem Time budget: 45 min

Booking a concert or movie seat sharpens the overselling problem from e-commerce to a knife’s edge: seats are unique (seat 14C, not “one of 100 units”), demand is spiky (tickets go on sale at 10am and sell out in seconds), and a seat must be held while the user pays but released if they don’t. The heart is seat reservation with expiry under extreme concurrency.

Prerequisites: Distributed Locking, Transactions, Design E-Commerce


1. Requirements

Functional:

Non-functional:

Out of scope: payment internals (separate), recommendations, dynamic pricing.


2. Estimation


3. The core: seat reservation with expiry

🚨 A seat has three states: AVAILABLE → HELD (reserved, with a TTL) → BOOKED. The tricky part is the HELD state — a temporary, expiring lock so a user can pay without losing the seat, but the seat frees up if they abandon.

stateDiagram-v2
    AVAILABLE --> HELD: user selects (TTL, e.g. 5 min)
    HELD --> BOOKED: payment confirmed
    HELD --> AVAILABLE: TTL expires / user cancels
    BOOKED --> AVAILABLE: refund/cancel (policy)

Holding a seat = an atomic conditional update: set seat HELD by user X WHERE seat is AVAILABLE. If it fails, someone else got it → tell the user immediately. The hold carries an expiry; a background sweep (or a TTL in Redis) releases expired holds. 🚨 This “reserve-then-confirm with a TTL” is the whole trick — it’s the same pattern as inventory reservation in e-commerce, but per unique seat.


4. High-level design

flowchart TB
    Client -->|view seat map| Read[Read path: cached availability]
    Client -->|hold seat| Booking[Booking Service]
    Booking -->|atomic hold + TTL| SeatDB[(Seat state store<br/>strongly consistent)]
    Booking --> Hold[(Hold registry<br/>Redis, TTL)]
    Client -->|pay| Payment[Payment Service]
    Payment -->|success| Booking
    Booking -->|HELD -> BOOKED| SeatDB
    Sweeper[Expiry sweeper] -.releases.-> SeatDB

Seat state lives in a strongly-consistent store (relational, or Redis with atomic ops for holds + a durable DB of record for bookings). The booking service serializes seat-state transitions.


5. Deep dives

5a. Atomic hold — preventing double-booking

Two users clicking seat 14C at once: the atomic conditional update ensures only one succeeds (the DB serializes it; the loser sees “seat unavailable”). Use a row lock / SELECT ... FOR UPDATE in a transaction, or an atomic Redis operation, or a per-seat distributed lock. 🚨 Never read-then-write without atomicity — that’s the classic race that oversells.

5b. The hold TTL and its expiry

A hold must auto-release if the user doesn’t pay. Options:

5c. Handling the on-sale spike (thundering herd)

10,000/sec for one show is a hotspot. Mitigations:

5d. Consistency vs the read path

Seat maps shown to browsers can be slightly stale (cached) — a user might click a seat that was just taken; the atomic hold catches it. 🚨 Eventual consistency for display, strong consistency for the actual hold/booking — don’t make every seat-map view hit the consistent store, but never trust the cached view for the booking decision.

5e. Booking confirmation & payment

Hold → user pays → on payment success, transition HELD→BOOKED atomically and durably record the booking. If payment fails or times out, release the hold. Use idempotency so a retried payment/confirmation doesn’t double-book or double-charge. Multi-seat orders: hold all seats, book all-or-nothing (transaction).


6. Bottlenecks & scaling further

  1. Seat contention → atomic conditional holds; per-show, per-seat granularity.
  2. On-sale spike → virtual waiting room, batched admission, fast rejection.
  3. Hold expiry correctness → TTL + sweeper + check-on-book (belt and suspenders).
  4. Read load (seat maps) → cache; tolerate slight staleness.
  5. Multi-show scale → partition by event/show; each show’s contention is isolated.

7. Trade-off summary

Decision Chosen Alternative Why
Seat hold Atomic conditional update + TTL Read-then-write Prevents double-booking under concurrency
Expiry TTL + sweeper + check-on-book Sweeper only Correctness doesn’t hinge on one mechanism
On-sale spike Virtual waiting room Let all in Protects the booking core from overload
Seat map display Cached (eventual) Always consistent read Cheap browsing; hold is the real gate
Seat state store Strongly consistent Eventually consistent A seat must go to exactly one person

8. Follow-up questions

How do you stop two people from booking the same seat? By making the seat hold an atomic conditional operation that the datastore serializes, so concurrent attempts are resolved to exactly one winner. When a user selects seat 14C, you attempt to transition it from AVAILABLE to HELD *conditionally* — only if it's still AVAILABLE — using a row lock (`SELECT ... FOR UPDATE` in a transaction), an atomic compare-and-set in Redis, or a per-seat distributed lock. The datastore processes concurrent holds for the same seat one at a time, so the first succeeds and every other sees the seat is no longer AVAILABLE and is immediately told "seat taken." The essential rule is never to read availability and then separately write the hold without atomicity, because that read-then-write gap is exactly where two users both see "available" and both proceed — the atomic conditional update closes that gap.
Why hold a seat temporarily instead of only booking on payment? Because payment takes time (entering card details, 3-D Secure, processor round-trips), and without a hold two bad things happen: either the seat stays bookable during that window so someone else can grab it out from under a paying user, or you'd have to book-then-refund on payment failure, which is messy and can double-sell in the meantime. A temporary hold with a TTL reserves the specific seat for the user for a few minutes so they can pay in peace, while guaranteeing the seat isn't lost to anyone else during that window. The TTL is what keeps it fair and prevents abandoned selections from locking seats forever — if the user doesn't complete payment in time, the hold expires and the seat returns to AVAILABLE for others. It's the reserve- then-confirm pattern, adapted to unique seats: reserve on selection, confirm on payment, auto-release on timeout.
A blockbuster show goes on sale and 10,000 requests/sec hit the same seats. How do you cope? The core seat-state store can't sanely take 10,000 contended writes/sec on one show's seats, so you protect it with a virtual waiting room that admits users in controlled batches rather than all at once — users get a queue position and are let through to the booking flow at a rate the system can handle (this is what Ticketmaster-style systems do). Browsing the seat map is served from cache so read load doesn't touch the consistent store. And you fail fast: the moment an atomic hold loses, tell that user the seat's gone instead of making them wait, freeing capacity. Partitioning by show means this hot event's contention is isolated to its own seats and doesn't affect other events. The combination — queue to smooth admission, cache for reads, fast rejection, and per-show isolation — turns an unmanageable instantaneous spike into a controlled flow the strongly-consistent booking core can serve correctly.
What if a user's hold expires at the exact moment they complete payment? This is a real race that you must resolve with an explicit policy rather than leaving to chance. The safe default is that the booking confirmation re-checks the hold atomically: transition HELD→BOOKED only if the hold is still valid (not expired and still owned by this user), in one atomic operation. If the hold already expired and the seat was reclaimed, the booking fails and you refund/void the payment and inform the user. To reduce how often this bites real users, many systems add a short grace period beyond the visible TTL, or stop accepting new payment attempts slightly before expiry. The key points are: never book on a stale assumption that the hold is still valid — verify atomically at confirmation time — and always pair a failed booking with a payment reversal so the user is never charged for a seat they didn't get. Combining lazy check-on-book with the active sweeper ensures neither mechanism alone has to be perfect.

9. What junior / mid / senior answers look like


Further reading