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:
- Browse seat availability for an event.
- Hold a seat: a temporary, exclusive reservation while the buyer checks out.
- Purchase: convert a hold into a sale after payment.
- Expire holds: release a seat if checkout is abandoned.
Out of scope (say it): payment internals, pricing, recommendations, resale.
The non-functional shape is the inverse of a feed:
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
| Quantity | Assumption | Result |
|---|---|---|
| Event size | ~50k seats | scarce, finite inventory |
| Onsale demand | ~1M users at 10:00:00 | ~20:1 buyers per seat, all at once |
| Steady state | browsing, far lower | only onsale is extreme |
| The contention | thousands of users per popular seat | the write hotspot is the whole problem |
| Inventory size | seats per event, modest | fits 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
| Endpoint | Does | Notes |
|---|---|---|
GET /events/:id/seats | availability map | read-mostly, cacheable; eventually consistent is fine for browsing |
POST /holds | hold a seat | atomic; succeeds for exactly one user; returns a hold with a TTL |
POST /purchase | buy a held seat | idempotency key; pays, then marks the seat sold; only the holder may call |
DELETE /holds/:id | release a hold | explicit 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
availabletoheld. A relational row with a conditional update (orSELECT 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.
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… | Mode | What happens |
|---|---|---|
| Concurrent claims on a seat | fail closed | atomic update; exactly one winner, no double-booking |
| Onsale overload | shed | waiting room queues and meters; no errors, just an honest wait |
| Payment fails or times out | fail closed | release the hold; never a sold seat without a captured payment |
| Buyer abandons checkout | expire | hold TTL lapses; the guarded update returns the seat to available |
| Inventory store node | degrade | failover 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
- The invariant as an SLI: double-booked seats = 0. Any non-zero value is a sev-1, the same way a ledger imbalance is for payments.
- Onsale health: waiting-room length and wait time, admit rate, and hold success rate. These tell you whether the metering is right.
- Inventory store load: write latency and contention on hot seats; the signal that the admit rate is too high.
- The three pillars: metrics for double-booking and queue depth, traces across hold and purchase, structured logs keyed by seat and hold id.
- Alert on correctness and the spike: page on any double-booking, on inventory write-latency burn, and on queue blowout, not raw CPU.
Maps to S10 (observability). See the observability cheat sheet.
Step 8
Trade-offs along the six core axes
| Axis (S1) | The call here | Why it is least-wrong |
|---|---|---|
| Consistency vs availability | consistency on inventory | a double-sold seat is worse than a slower or queued onsale |
| Throughput vs contention | waiting room meters load | convert an un-serveable spike into a rate the store can handle |
| Latency vs correctness | atomic claim, serialized per seat | let the database adjudicate the race; correctness over a few ms |
| Fairness vs throughput | first-come queue | position-based admission beats a retry-storm lottery |
| Simplicity vs capability | a guarded SQL update, not a distributed lock | the row lock the DB already gives you is enough and is simpler |
| Coupling vs cohesion | hold then async purchase | decouple 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
- Slot: Week 6 worked design, and the last of the eight. The strong counterpart to Stripe: both are correctness-first, but here the lever is contention and inventory, not idempotency and a ledger.
- Run it live: walk steps 1 to 5, then write the naive check-then-set on the board and let the room find the double-booking before revealing the atomic update.
- The one sentence: "meter the flood with a waiting room, and claim each seat with one atomic conditional update so exactly one buyer can ever win it."