A ticket-booking platform looks like e-commerce until you notice the brutal twist: when a hot concert goes on sale, millions of people try to buy a few thousand specific seats in the same instant. That combination — a tiny, contended inventory plus an enormous concurrent spike — makes two requirements collide. You must never sell the same seat twice (strong consistency on inventory), and you must survive a thundering herd without falling over. The design centers on seat holds with proper locking, and a virtual waiting room to tame the spike. It shares its inventory-locking core with our hotel booking design.

⚡ Quick Takeaways
  • Never oversell — booking inventory needs strong consistency; the seat-claim must be an atomic operation, not a read-then-write race.
  • Temporary holds — selecting a seat puts it in a held state for a few minutes while the user pays; if they don't, the hold expires and the seat returns.
  • Lock the seat atomically — a conditional update (available → held only if still available), SELECT ... FOR UPDATE, or a Redis lock; whoever wins gets the seat, everyone else is told it's gone.
  • Virtual waiting room — for a hot onsale, queue users and admit them in controlled batches so the booking backend sees steady load, not a tsunami.
  • Split the paths by consistency need — browsing events/seat maps is read-heavy and cacheable (AP); the booking path is strongly consistent (CP).
  • Holds + payment = a saga — hold the seat, take payment, confirm; release the hold if payment fails or times out.
tldr

The hard part is selling each seat exactly once under massive contention. Model a seat as a state machine (available → held → booked) and claim it with an atomic conditional update or row lock so concurrent buyers can't both win. Hold a seat for a few minutes during checkout, releasing it on timeout. Absorb onsale spikes with a virtual waiting room that admits users in batches. Cache the read-heavy browse path; keep the booking path strongly consistent; and orchestrate hold→pay→confirm as a saga.

booking flow
 millions ─▶ ┌────────────────┐ admit in batches ┌──────────────┐
  at onsale  │ Virtual Waiting│─────────────────▶│   Booking    │
             │ Room (queue)   │                  │   Service    │
             └────────────────┘                  └──────┬───────┘
                                       atomic claim      │
   browse (cached, AP) ───▶ read replicas / CDN          ▼
                                            ┌──────────────────┐
                                            │  Inventory DB    │ seat:
                                            │ (strong, CP)     │ available
                                            └──────────────────┘ → held → booked
                                  hold→pay→confirm (saga) ─▶ Payment Service

Step 1 — Clarify Requirements

Functional: browse events; view an event's seat map with availability; reserve (hold) one or more specific seats; complete purchase; release holds that aren't paid for. Non-functional: no double-booking / no overselling (the headline correctness requirement), strong consistency on the booking path, very high availability and throughput for browsing, and the ability to absorb extreme traffic spikes at onsale. Fairness matters too (a chaotic free-for-all frustrates users and invites bots), which motivates the waiting room. Assume reserved seating (specific seats), which is harder than general admission (a simple counter).

Step 2 — Capacity Estimation

Steady-state traffic is modest, but onsales are pathological: a stadium tour might put 50K seats on sale while 2–10 million people refresh simultaneously in the first minute. So the read (browse/seat-map) path must handle millions of QPS in bursts, while the write (booking) path handles a comparatively small but intensely contended volume — thousands of users fighting over each remaining seat. This asymmetry is the whole story: scale reads with caching, protect the small consistent write path with locking and a queue.

Step 3 — API Design

core API
GET  /events/{id}                       # event details
GET  /events/{id}/seats                 # seat map + availability (cached)
POST /reservations  {event_id, seat_ids}# place a HOLD (returns hold + expiry)
POST /bookings      {hold_id, payment}  # confirm + pay → booked
DELETE /reservations/{hold_id}          # release early

Step 4 — The Core Challenge: No Double-Booking

The fundamental hazard is a race: two users see seat 12A as available, both click "reserve," and a naive read-then-write ("check it's free, then mark it taken") lets both succeed — overselling. The fix is to make claiming a seat an atomic, conditional operation that only one concurrent request can win. This is the same lost-update / check-then-act problem covered in our DDIA transactions notes; the seat is the contended resource and the database (or a lock) must serialize access to it.

Step 5 — Seat Holds (Reservation State Machine)

Buying takes time (choose seats, enter payment), so you can't simply mark a seat sold on click — but you also can't leave it available for others to grab mid-checkout. The answer is a temporary hold: selecting a seat moves it to a held state for a short window (e.g. 5–10 minutes) reserved for that user. A seat is a small state machine:

StateMeaningTransition
availableAnyone can claim it→ held (on reserve)
heldReserved for one user, with an expiry→ booked (paid) / → available (timeout)
bookedSold — terminal(refund is a separate flow)

Step 6 — Concurrency Control: Claiming a Seat Atomically

The hold transition must be atomic so only one of N simultaneous requests succeeds. Options:

atomic conditional claim (optimistic)
UPDATE seats
   SET status = 'held', held_by = :user, hold_expires = now()+interval '8 min'
 WHERE seat_id = :seat
   AND status = 'available';          # the guard makes it atomic

# rows_affected = 1 → you got the seat
# rows_affected = 0 → someone else won; tell the user "seat gone"
MechanismHowTrade-off
Conditional UPDATE (optimistic)Update only if status='available'Simple, no held locks; loser just retries another seat
SELECT ... FOR UPDATE (pessimistic)Lock the row, then updateClear, but holds DB locks under high contention
Redis distributed lockLock per seat key with TTLOffloads hot contention from the DB; needs care for correctness

The optimistic conditional update is usually the cleanest answer: it needs no long-held lock, and under contention all but one updater simply get rows_affected = 0 and are told the seat is taken. The WHERE status='available' guard is what makes the whole thing safe.

Step 7 — Hold Expiration

Holds must be released if the user abandons checkout, or seats leak and the event looks falsely sold out. Combine mechanisms (the same pattern as expiring links or pastes): lazy — when anyone reads or tries to claim a seat whose hold_expires has passed, treat it as available again; and scheduled — a background sweeper periodically flips expired held seats back to available. Some designs use a Redis key with a TTL per hold so expiry is automatic. The hold window is a tuning knob: long enough to comfortably pay, short enough that abandoned seats recirculate quickly during a hot onsale.

Step 8 — Data Model

schema
events (event_id, name, venue_id, starts_at, onsale_at)
seats  (seat_id, event_id, section, row, number,
        status,          # available | held | booked
        held_by, hold_expires)        # indexed for the sweeper
bookings (booking_id, user_id, event_id, seat_ids[], payment_id, ts)

An ACID relational database is the natural fit for the inventory because the atomic conditional claim relies on transactional guarantees. Shard by event_id so each event's seats live together — convenient, but it means a single hot event is one shard's problem (Step 11).

Step 9 — Taming the Spike: the Virtual Waiting Room

No inventory database can take direct hits from 5 million users in the same second. The standard solution — and what Ticketmaster actually does — is a virtual waiting room. When an onsale starts, incoming users are placed in a queue (often a Redis-backed list with a position token) and shown a "you're in line" page. The system then admits users into the real booking flow in controlled batches, sized to what the backend can safely handle. This converts an unbounded spike into a steady, bounded stream — the booking path never sees more concurrency than it can serialize, and users get a fair, orderly experience instead of a crashing free-for-all.

why this is the key move

The waiting room decouples arrival rate from processing rate. Without it, you'd have to make the booking backend absorb millions of concurrent lock attempts on a few thousand rows — a recipe for lock contention meltdown. With it, the backend always operates within its safe envelope, and back-pressure is pushed to a cheap queue. It also blunts bots and gives you a fairness/anti-abuse control point.

Step 10 — Consistency: Split the Paths

Different parts of the system have opposite needs, so treat them differently:

This is a direct CAP-style split (see consistency & consensus): be available and approximate where it's harmless, be consistent and authoritative where money and oversell are on the line.

Step 11 — Payment Integration

Confirming a booking spans the inventory store and an external payment provider, so it's orchestrated as a saga (see our payment system design): hold the seat → take payment (idempotently) → on success, transition the seat to booked and record the booking; on payment failure or timeout, run the compensating action and release the hold so the seat returns to inventory. The hold's expiry is the safety net — even if a step is lost, the seat won't stay stuck in held forever.

Step 12 — Scaling and the Hot-Event Hotspot

The browse path scales horizontally with caching, replicas, and CDN. The booking path is sharded by event, which works well across many events — but a single blockbuster onsale concentrates all contention on one event's seats (a hotspot). Mitigations: the waiting room caps concurrency into that shard; seats can be partitioned within the event (by section) to spread lock contention; and an in-memory layer (Redis) can front the hottest seat-status checks. Crucially, the contention is bounded by the seat count — there are only so many seats to fight over — so once the waiting room throttles arrivals, the backend's job is finite and tractable.

Step 13 — Key Tradeoffs

takeaway

Ticketmaster is two problems wearing one coat: sell each seat exactly once (solved by a seat state machine plus an atomic conditional claim), and survive millions of simultaneous buyers (solved by a virtual waiting room that throttles arrivals into the consistent booking path). Split the architecture by consistency need — cache the browse path, protect the booking path — and orchestrate hold→pay→confirm as a saga with hold expiry as the safety net.

🎯 interview hot-takes

How do you prevent double-booking? Claim the seat with an atomic conditional update (SET held WHERE status='available') or row lock, so only one of many concurrent requests wins.
Why temporary holds? Checkout takes time; a hold reserves the seat for a few minutes (state available→held→booked) and expires back to available if unpaid, so seats neither double-sell nor leak.
How do you handle an onsale spike of millions? A virtual waiting room queues users and admits them in batches, decoupling arrival rate from the booking backend's safe processing rate.
Where's the consistency boundary? Browsing/seat-maps are cached and AP; the booking path is strongly consistent (CP) — the atomic claim is the single source of truth.
How does payment fit in? Hold → pay (idempotently) → confirm, as a saga; on failure or timeout the hold is released, with expiry as the backstop.

← previous
Design a Payment System