hub.gazar.dev · senior-to-staff · worked system design

Design Ticketmaster: event ticketing

A fully worked answer in the course order: requirements, numbers, API, data, the high-level diagram, the deep dives that actually decide it, then reliability, observability, and the trade-offs. The lever here is contention: a million people trying to buy the same few thousand seats at 10am, with the absolute rule that no seat is ever sold twice. Back to the course.

The prompt, scoped

"Design Ticketmaster" is "sell scarce inventory under extreme contention, correctly." We keep the spine: browse seats, hold a seat while you check out, purchase it, and release abandoned holds, all without ever double-selling. Out of scope, said up front: payment internals (that is the Stripe design), dynamic pricing, recommendations, fraud. The hard part is the onsale spike and the no-double-booking guarantee.

Step 1

Requirements: functional and non-functional

The functional core:

Out of scope (say it): payment internals, pricing, recommendations, resale.

The non-functional shape is the inverse of a feed:

no double-booking, ever strong consistency on inventory extreme contention at onsale fairness correctness > availability

The defining fact: a single seat is a unique, contended resource, and selling it twice is unacceptable. That forces strong consistency on the seat, and the onsale spike (a flood of buyers for scarce seats) forces a way to admit that flood without melting the inventory store.

Maps to S2 (requirements) and S1 (consistency over availability for inventory).

Step 2

Estimation: the numbers that bound the design

QuantityAssumptionResult
Event size~50k seatsscarce, finite inventory
Onsale demand~1M users at 10:00:00~20:1 buyers per seat, all at once
Steady statebrowsing, far loweronly onsale is extreme
The contentionthousands of users per popular seatthe write hotspot is the whole problem
Inventory sizeseats per event, modestfits a consistent store; QPS, not size, is the issue

The number that decides the architecture

The buyers-to-seats ratio at onsale, ~20:1 and spiking in one second. That rules out letting every buyer hit the inventory database directly (it would collapse), and it rules out any non-atomic "check then reserve" (it would double-book). The design is a waiting room to meter the flood plus an atomic claim to guarantee one winner per seat.

Maps to S2 (capacity estimation, spike not steady-state).

Step 3

API: hold then purchase

EndpointDoesNotes
GET /events/:id/seatsavailability mapread-mostly, cacheable; eventually consistent is fine for browsing
POST /holdshold a seatatomic; succeeds for exactly one user; returns a hold with a TTL
POST /purchasebuy a held seatidempotency key; pays, then marks the seat sold; only the holder may call
DELETE /holds/:idrelease a holdexplicit cancel; expiry also releases automatically

The two-step hold-then-purchase is the heart of it: holding gives the buyer exclusive time to pay without locking the seat forever, and purchase is the idempotent commit. Browsing is a separate, cacheable read.

Maps to S5 (API design): a reservation with a TTL, an idempotent commit. See the API cheat sheet.

Step 4

Data model and store choices

Event(id PK, name, onsale_at, venue_id)
Seat(id PK, event_id, section, status=available|held|sold, held_by, hold_expires_at)
Hold(id PK, seat_id, user_id, expires_at)
Order(id PK, seat_id, user_id, status, idempotency_key)

invariant:  a seat is held_by / sold to AT MOST one user at a time

stores
  Seat inventory .. the contended write hotspot; needs ATOMIC updates   -> relational (row locks) / strongly-consistent
  Waiting room .... a fair queue of users waiting to enter onsale        -> a queue / token service (Redis)
  Orders .......... durable record of sales                              -> relational

Why a strongly-consistent inventory store

  • The seat is a uniquely owned resource; correctness requires that exactly one transaction can move it from available to held. A relational row with a conditional update (or SELECT FOR UPDATE) gives that atomicity directly.
  • Inventory is small (seats per event), so the constraint is contention, not size: do not reach for an eventually-consistent store here, you would trade away the one property you cannot lose.

Maps to S3 (store from access patterns: atomic claims) and S4 (the hotspot is contention, not volume). See the data-stores cheat sheet.

Step 5

High-level architecture

Two guards in front of one consistent store. The waiting room meters the onsale flood into a rate the system can serve, admitting users in fair batches. The inventory service performs the atomic hold and sale against the strongly-consistent inventory store. A hold-expiry worker returns abandoned seats to available, and purchase calls out to the payment provider. The waiting room protects throughput; the atomic claim protects correctness.

Users ~1M at onsale Waiting room meter + fair queue Inventory service atomic hold / sell Inventory store strongly consistent Payment provider Stripe · idempotent Hold-expiry worker release abandoned onsale spike admit (throttled) atomic hold / sell purchase: charge release expired holds
The waiting room (green) meters ~1M users into a serveable rate and admits them fairly. The inventory service performs an atomic hold or sale against the strongly-consistent inventory store (blue). Purchase charges the external payment provider (orange). A hold-expiry worker returns abandoned seats to available. Throughput is protected by the queue; correctness by the atomic claim.

Maps to S7 (architectural styles) and S6 (atomic claims, queues, backpressure).

Step 6

Deep dive A: high-contention inventory, no double-booking

The correctness core. Thousands of users try to grab the same seat at the same instant; exactly one must win, and the rest must cleanly lose. The answer is an atomic claim, never a read-then-write.

the bug: check-then-set (NOT atomic)
   if seat.status == available:   # two requests both read "available"
       seat.status = held          # both write "held" -> DOUBLE BOOKED

the fix: one atomic conditional update
   UPDATE seat
      SET status='held', held_by=:user, hold_expires_at=now()+2min
    WHERE id=:seat AND status='available'      <-- the guard
   rows_updated == 1 ?  you got it
   rows_updated == 0 ?  someone else won; pick another seat

   the database serializes writes to the row, so exactly one of the
   thousand concurrent updates sees status='available' and wins.

Why atomic, and fail-closed

The single conditional update pushes the race into the database, which is built to serialize row writes, so it produces exactly one winner with no application-level lock dance. It is fail-closed: if you cannot prove you won the row (one row updated), you did not get the seat. The same compare-and-set idea protects the driver in the Uber design; here it protects the seat.

Maps to S6 (atomic operations, optimistic concurrency) and S9 (fail closed on the claim).

Step 6 · continued

Deep dive B: the onsale spike and the waiting room

Atomicity makes each claim correct, but a million simultaneous claims would still flatten the inventory store. The virtual waiting room turns a flash flood into a managed stream, and gives fairness as a bonus.

without a waiting room
   1M users hit POST /holds at 10:00:00 -> the inventory DB melts,
   everyone sees errors, and it is unfair (loudest client wins)

virtual waiting room
   - at onsale, users enter a QUEUE (a token with a position)
   - the system admits users in batches at a rate the inventory
     store can actually serve (e.g. N per second)
   - admitted users get a short-lived token to attempt holds
   - everyone else waits, sees their position, and is admitted in order

effect
   - smooths the spike into a steady, serveable flow (backpressure)
   - fairness: first-come-first-served, not first-to-retry-hardest
   - load shedding happens politely in the queue, not as 500s

The senior point

You cannot make scarce inventory un-scarce, so do not try to serve everyone at once. The waiting room converts an un-serveable spike into a rate you chose, protecting the consistent store behind it and giving users a fair, predictable queue instead of a lottery of errors. It is backpressure and load shedding, made visible and fair.

Maps to S9 (load shedding, backpressure) and S4 (protect the hotspot). See the resilience cheat sheet.

Step 6 · continued

Deep dive C: holds, expiry, and purchase

A hold gives the buyer time to pay without locking the seat forever. The subtlety is the race between expiry and purchase, which must never let a seat be both released and sold.

hold lifecycle
   available --hold--> held (TTL ~2 min) --purchase--> sold
                          |
                          +-- TTL elapsed / cancel --> available

expiry without a race
   - do NOT trust a background sweeper alone to flip held->available
   - the atomic guard already encodes it:
       a hold is only valid WHERE status='held' AND hold_expires_at > now()
   - purchase: UPDATE ... SET status='sold'
               WHERE id=:seat AND held_by=:user AND hold_expires_at > now()
       -> if the hold expired, purchase fails cleanly (rows=0)
   - the sweeper is just cleanup; correctness lives in the guarded update

payment
   - charge via the payment provider with an idempotency key
   - payment fails -> release the hold; never a sold seat with no payment

Why the guard, not the sweeper, is the source of truth

If a background job were the only thing flipping expired holds back to available, it could race a late purchase and either double-sell or wrongly release. Putting hold_expires_at > now() into the purchase update itself makes the database adjudicate the race atomically. The sweeper becomes a tidy-up, not a correctness dependency.

Maps to S6 (atomic state transitions) and S9 (no expiry-vs-purchase race).

Step 7

Reliability & failure design

If this fails…ModeWhat happens
Concurrent claims on a seatfail closedatomic update; exactly one winner, no double-booking
Onsale overloadshedwaiting room queues and meters; no errors, just an honest wait
Payment fails or times outfail closedrelease the hold; never a sold seat without a captured payment
Buyer abandons checkoutexpirehold TTL lapses; the guarded update returns the seat to available
Inventory store nodedegradefailover replica; writes still serialized per seat, correctness preserved

Correctness is non-negotiable, so every seat transition is a fail-closed atomic update: when in doubt, you did not get the seat. Availability is bought differently, by the waiting room metering load so the consistent store is never asked for more than it can do. Payment is idempotent and a failed charge releases the hold, so the system never ends in a sold-but-unpaid or paid-but-unsold state.

Maps to S9 (designing for failure): fail closed on the seat, shed load at the door.

Step 7 · continued

Observability & operability

Maps to S10 (observability). See the observability cheat sheet.

Step 8

Trade-offs along the six core axes

Axis (S1)The call hereWhy it is least-wrong
Consistency vs availabilityconsistency on inventorya double-sold seat is worse than a slower or queued onsale
Throughput vs contentionwaiting room meters loadconvert an un-serveable spike into a rate the store can handle
Latency vs correctnessatomic claim, serialized per seatlet the database adjudicate the race; correctness over a few ms
Fairness vs throughputfirst-come queueposition-based admission beats a retry-storm lottery
Simplicity vs capabilitya guarded SQL update, not a distributed lockthe row lock the DB already gives you is enough and is simpler
Coupling vs cohesionhold then async purchasedecouple the exclusive reservation from the slow payment step

The whole design is two guards around one truth: a waiting room so the flood arrives at a serveable rate, and an atomic conditional update so exactly one buyer ever wins a seat. Everything else, holds, expiry, payment, hangs off that. Say it and the boxes follow.

Maps to S1 (the six core trade-offs) and S12 (present and defend).

Use this in the cohort