Knowledge Guide
HomeHands-On BuildsSD Design Labs

Design Ticketmaster

Design Ticketmaster (event ticketing under a flash crowd)

The whole system is easy right up until it isn't. Selling movie tickets on a Tuesday afternoon is a CRUD app: read seats, pick one, write a row, done. Then a Taylor Swift stadium tour goes on sale and 50,000 people click "buy" for the same 10,000 seats in the same three seconds — and the naive design, the one that reads the seat, sees "available," and writes "booked," sells the same seat to five different people. Two of them show up to the stadium with a valid barcode for seat 114-C. That is the interview: not "can you model a ticket," but "can you make one seat go to exactly one buyer when tens of thousands of buyers race for it at once." This lab derives the design that survives the flash crowd, and quantifies exactly how badly the obvious approach fails before we fix it.

1. The Trap — check-then-book quietly sells the same seat five times

Here's the code everyone writes first. It looks obviously correct:

// NAIVE booking — check, then book
seat = db.query("SELECT status FROM seats WHERE seat_id = ?", seatId)
if (seat.status == "AVAILABLE") {         // (A) read
    db.exec("UPDATE seats SET status='BOOKED', owner=? WHERE seat_id=?", user, seatId)  // (B) write
    return "you got it!"
}
return "sold out"

The bug is the gap between (A) and (B). Between reading "available" and writing "booked," nothing stops another request from reading the same "available." Two buyers both pass the if, both run the UPDATE, both get "you got it!" — the second write simply overwrites the first owner. One seat, two barcodes.

Let's quantify it, because "it's a race, it could happen" is not an interview answer — a number is. Take one hot on-sale: N = 50,000 buyers converge on S = 10,000 seats, and the booking attempts land over a burst window of T = 2 seconds. The read-to-write gap (A→B: network hop + app logic + the UPDATE round-trip) is a modest δ = 10 ms.

Assume buyers spread evenly over seats (the OPTIMISTIC case — reality is far worse,
everyone rushes the front rows):
  attempts per seat        r = N / S = 50,000 / 10,000 = 5
  pairs of attempts/seat   C(5,2) = 10
  P(two attempts collide)  ≈ 2δ/T = 2(0.01)/2 = 0.01   (both land within 10 ms of each other)
  double-booked seats      ≈ pairs × P × S
                           = 10 × 0.01 × 10,000
                           ≈ 1,000 seats sold to two people

~1,000 of the 10,000 seats — 10% of inventory — double-sold in the first two seconds, and that's the best case where buyers are spread perfectly evenly. Everyone actually fights over the front-section "best available" seats, so a single hot seat can take 50+ simultaneous attempts and be sold a dozen times over. "The window is only 10 milliseconds, it'll basically never happen" is the exact intuition that fails: a tiny per-attempt window, multiplied by tens of thousands of attempts hammering the same rows, is a mathematical certainty of mass double-booking. This is not a rare edge case. It is what your naive design does on its most important day.

2. Scope it like a senior (ask before you draw)

Don't reach for the whiteboard. Each of these answers changes the design materially:

Locked assumptions for this lab: assigned seats; a 5-minute hold before payment; payment happens after the hold (hold is cheap, held for ms not seconds); a virtual waiting room for flash on-sales; 10M tickets/month steady, 50K concurrent buyers peak on one hot event; strong consistency on the seat (never double-book) is non-negotiable, availability of the browse path can degrade gracefully.

3. Reason to the design (derive the seat-hold state machine, don't recite it)

Step 1 — a seat is a tiny state machine, not a boolean. "Available / booked" is too coarse: it has no room for "someone is mid-purchase." Model three states:

  AVAILABLE  —holdSeat→  HELD(owner, expires_at = now+5min)  —confirmBooking→  BOOKED
      ↑______________________________________|
              TTL expiry  OR  explicit release

HELD is the insight: it's a temporary, owned, self-expiring reservation that gives a human time to pay without letting them squat a seat forever. BOOKED is terminal. The expiry edge (HELD→AVAILABLE after 5 min) is what recycles seats abandoned in checkout.

Step 2 — the crux: making the AVAILABLE→HELD transition atomic. Everything in this problem reduces to one question: how do two concurrent holdSeat calls on the same seat get serialized so exactly one wins? Three real mechanisms, in order of how I'd reach for them:

Step 3 — a hold must expire, and expiry is a background sweep, not magic. A HELD seat carries hold_expires_at. A worker (or a lazy check on the next read) flips HELD→AVAILABLE once hold_expires_at ≤ now. Because every hold uses the same fixed 5-minute TTL, insertion order equals expiry order — a single ordered structure (or an indexed scan on hold_expires_at) makes the sweep cheap.

Step 4 — shed the stampede with a virtual waiting room. Even with a perfectly atomic hold, 50K buyers slamming one event's seat rows will melt the database on lock contention and connection-pool exhaustion (traced in Break It #3). The fix is upstream: a virtual waiting room queues arrivals and admits them to the booking service at a metered rate (say, a few thousand/second) in FCFS order. It converts a stampede into a controlled drain and gives you fairness for free. The atomic hold guarantees correctness; the waiting room protects availability. You need both.

4. Build it — the design worksheet

This is the artifact you'd produce on the whiteboard in a 45-minute round. Numbers are traced, not asserted — recompute them before an interview.

Functional requirements

Non-functional requirements

Back-of-envelope estimation (BOTE)

API sketch

POST /events/{eventId}/holds
     body: { seatIds: [..], userId, waitingRoomToken }
     -> 201 { holdId, heldSeats, holdExpiresAt }      // atomic; all-or-nothing
     -> 409 SEAT_UNAVAILABLE  { conflictingSeatIds }   // someone else won
     -> 429 NOT_ADMITTED      // waiting-room token not yet admitted

POST /holds/{holdId}/confirm
     header: Idempotency-Key: <uuid>
     body: { paymentToken }
     -> 200 { bookingId, seats }                       // hold re-checked valid, then BOOKED
     -> 410 HOLD_EXPIRED       // TTL lapsed before payment landed (refund if charged)

DELETE /holds/{holdId}        -> 204                    // explicit release, seats back to AVAILABLE

GET  /events/{eventId}/seatmap -> { seats:[{seatId,status}] }   // cache/replica, eventually-consistent

Data model & partitioning

High-level diagram

The waiting room meters the stampede down to a safe admit rate; the booking service owns the state machine and makes the AVAILABLE→HELD transition atomic against the seat-hold store; a background expiry worker recycles abandoned holds; payment sits after the fast hold and re-checks the hold's validity before flipping HELD→BOOKED.

Ticketmaster hot-event booking flow: 50K buyers enter a Virtual Waiting Room that queues and admits them at a metered rate to the Booking Service; the Booking Service owns the Available-Held-Booked state machine and performs holdSeat as an atomic conditional UPDATE (WHERE status='AVAILABLE') against the Seat-Hold Store, which is partitioned by event_id so a hot event is one hot partition; an Expiry Worker sweeps expired holds back to available out of the hot path; the Payment Service sits after the fast hold, uses an idempotency key, and re-checks the hold before flipping Held to Booked.
Ticketmaster hot-event booking flow: 50K buyers enter a Virtual Waiting Room that queues and admits them at a metered rate to the Booking Service; the Booking Service owns the Available-Held-Booked state machine and performs holdSeat as an atomic conditional UPDATE (WHERE status='AVAILABLE') against the Seat-Hold Store, which is partitioned by event_id so a hot event is one hot partition; an Expiry Worker sweeps expired holds back to available out of the hot path; the Payment Service sits after the fast hold, uses an idempotency key, and re-checks the hold before flipping Held to Booked.

Rubric — what a strong answer hits

5. Break it — three traced failures

Failure 1 — the check-then-act double-booking race, exact interleaving. Two buyers, one seat 5A, naive read-then-write:

  time    Buyer A                          Buyer B                          seat 5A
  ----    ------------------------------   ------------------------------   -----------
  t0      SELECT status → 'AVAILABLE'                                       AVAILABLE
  t1                                       SELECT status → 'AVAILABLE'      AVAILABLE
  t2      if(AVAILABLE) → true                                             AVAILABLE
  t3                                       if(AVAILABLE) → true             AVAILABLE
  t4      UPDATE status='BOOKED', A                                         BOOKED (A)
  t5                                       UPDATE status='BOOKED', B         BOOKED (B) ← A silently lost
  t6      return "you got it!"             return "you got it!"             ONE seat, TWO barcodes

Fix: collapse read+write into one atomic conditional statement — UPDATE seats SET status='HELD',held_by=? WHERE seat_id=? AND status='AVAILABLE'. The row's write lock forces t4 and t5 to serialize and re-evaluate the predicate; B's UPDATE now matches 0 rows and B is told "sold." This is the break-it test: run 1,000 threads holding the same seat — with the naive version, assert you see >1 "success"; that failing assertion (success count > 1) IS the lesson. With the conditional UPDATE, exactly 1 thread gets affected_rows=1 and 999 get 0.

Failure 2 — hold expiry races payment completion. Payment is in flight while the TTL lapses:

  t=4:58   Buyer A submits payment (hold expires at 5:00)
  t=5:00   Expiry Worker: hold_expires_at ≤ now → seat 5A HELD→AVAILABLE, resold to Buyer C as HELD
  t=5:01   A's payment succeeds → naive confirm blindly sets 5A BOOKED to A
           → A is charged AND C holds/books 5A. Double-sold again, plus a chargeback.

Fix, two parts: (1) confirmBooking must be a conditional transaction: UPDATE seats SET status='BOOKED',booking_id=? WHERE seat_id=? AND held_by=A AND status='HELD' AND hold_expires_at > now(). If A's hold already expired/was reassigned, this matches 0 rows → reject the confirm and refund A's charge (payment is reversible; a double-booked seat is not). (2) Give the server-side TTL a ~5-second safety buffer beyond the client's countdown, so the worker never reclaims a seat while the buyer's own timer still shows time left. Idempotency-Key on confirm makes a retried "success" safe.

Failure 3 — one hot event = one hot partition, DB melts. You sharded by event_id, felt clever, and the Taylor Swift on-sale sends all 5,000 holds/s to the single node owning that event. Even with correct atomic holds, contending transactions queue on row/page locks; each waiting transaction pins a connection; the pool (say 200 connections) drains in milliseconds; new requests can't even get a connection and time out — the whole event freezes, including buyers who would have succeeded. Correctness held, availability collapsed.

Fix: (1) the virtual waiting room meters admits to a rate the partition can sustain (~1,000/s), turning a 5,000/s stampede into a steady drain. (2) Lock at per-seat granularity, never per-section/per-event, so non-conflicting buyers proceed in parallel. (3) If one event still saturates one node, sub-shard that event's seats by section across nodes so the load fans out. Sharding by event alone is necessary but not sufficient for a flash sale — that's the trap.

6. Optimise — with trade-offs vs named alternatives

Every row is "buys X at cost Y, use when Z, not W." Defend each in the room.

DecisionOption AOption BPick when
Atomic seat-hold mechanism Optimistic conditional/CAS UPDATE (WHERE status='AVAILABLE', check affected_rows) Pessimistic SELECT FOR UPDATE (explicit row lock, then update) CAS for a single-seat hold — one round trip, no lock held across app logic, and contention on a given seat is naturally low (only the handful racing that exact seat). Reach for SELECT FOR UPDATE when the hold decision spans multiple rows atomically ("4 adjacent seats or none") or you need to read-then-decide within one transaction. A third option, a distributed lock (Redis/Redlock), is fast and offloads the DB but adds a second source of truth with murky failover — use it only for the ephemeral hold, never as the sole guardian of the committed sale.
When to charge the card Hold-then-pay (fast atomic hold; payment after, off the lock) Pay-immediately (charge inside the hold transaction) Hold-then-pay almost always — the seat lock is held for milliseconds, not the seconds a payment gateway takes, so the hot partition isn't pinned by slow external I/O. It costs you the expiry-vs-payment race (Break It #2), which a conditional confirm + safety buffer solves. Pay-immediately only for ultra-low-contention GA sales where holding a lock through payment is harmless and you want zero abandoned-cart complexity.
Flash-crowd load control Virtual waiting room (FCFS queue, metered admit) Rate-limit / let-it-fail (429 the overflow, retry) Waiting room when the event is a marquee on-sale where fairness and UX matter (a visible "#34,102 in line" beats random 429s) and you must protect the DB from a stampede. Plain rate-limiting/shedding is fine for steady traffic or lower-stakes events where you don't owe buyers an ordered, fair queue — it's simpler but feels arbitrary and re-stampedes on retry.
Lock granularity Per-seat lock Per-section (or per-event) lock Per-seat — maximizes parallelism: two buyers wanting different seats never block each other, which is exactly what you need when thousands buy simultaneously. Per-section locking is simpler and avoids some deadlock ordering concerns for multi-seat holds, but it serializes an entire section behind one buyer — acceptable only for tiny venues or low concurrency, never for a stadium flash sale.
Seat-map (browse) consistency Eventually-consistent cache/replica Strongly-consistent read from primary Eventually-consistent for the map — it's read 300× more than it's written, and a seat briefly showing "available" then 409-ing on hold is correct behavior (the atomic hold is the arbiter). Strong reads from primary only if you can't tolerate any stale display, which needlessly loads the primary and buys little — the map is a hint, the hold is the truth.

7. Defend under drilling

Q1 — "How, exactly, do you prevent double-booking? Walk me through the one line that guarantees it."
A single atomic conditional write: UPDATE seats SET status='HELD', held_by=? WHERE seat_id=? AND status='AVAILABLE'. The database takes a row write-lock and re-evaluates status='AVAILABLE' at write time, so of N concurrent buyers racing that seat, exactly one transaction finds the predicate true and gets affected_rows=1; the rest get 0 and are told "sold." There is no read-then-write gap to exploit because the check and the mutation are the same statement. That's the guarantee — not a lock I remembered to take, but a predicate the DB enforces atomically.

Q2 — "What happens if payment succeeds but the hold already expired?"
This is the nastiest race (Break It #2). confirmBooking is itself conditional: ... SET status='BOOKED' WHERE seat_id=? AND held_by=? AND status='HELD' AND hold_expires_at > now(). If the hold lapsed and the seat was reassigned, this matches 0 rows → I reject the booking and refund the charge. The invariant: a payment is reversible, a double-booked seat is not, so on conflict I always unwind the money, never the seat. I also add a ~5-second server-side safety buffer so the reaper never reclaims a seat while the buyer's own countdown still shows time, and an Idempotency-Key so a retried confirm can't double-charge or double-book.

Q3 — "Optimistic CAS or pessimistic SELECT FOR UPDATE — which, and why?"
CAS for the common single-seat hold: one round trip, no lock held across application logic, and per-seat contention is genuinely low (only the few racing that exact seat). I switch to SELECT FOR UPDATE when a hold must span multiple rows atomically — "these 4 adjacent seats or none" — where I need to lock all of them, verify, then update in one transaction. Both give the same no-double-book guarantee; the choice is about transaction shape, not correctness. What I would not do is make Redis the sole arbiter of the committed sale — its failover can lose a lock and surface two holders.

Q4 — "Why a waiting room? Can't you just autoscale the booking service?"
Autoscaling app servers doesn't help, because the bottleneck isn't CPU — it's the single hot partition owning that event's seat rows. More app servers just means more connections contending on the same rows and draining the pool faster (Break It #3). The waiting room attacks the actual constraint: it meters admits to the rate that one partition can safely serialize, and gives fairness (FCFS) as a bonus. It protects availability; the atomic hold protects correctness. Different problems, different tools.

Q5 — "Seat map shows a seat available, but the hold 409s. Is that a bug?"
No, that's correct by design. The seat map is an eventually-consistent, cache/replica-served hint (read 300× more than written); the atomic hold is the single arbiter of truth. Making the map strongly consistent would hammer the primary for a display that's stale the instant it's rendered anyway. The right UX is optimistic display + authoritative hold: show the map fast, and let the 409 gracefully tell the late buyer "just taken, here are others."

The 100× escalation — "Taylor Swift Eras on-sale: not 50K but 2M+ in the queue for one event. What breaks first, and what do you change?"
At 2M concurrent the single-partition ceiling is the wall. Three moves, in order: (1) The waiting room becomes load-bearing infrastructure, not a nicety — it absorbs all 2M, hands out FCFS admission tokens, and drips buyers to booking at the partition's safe rate; queue depth and wait time (~2M ÷ 1,000/s ≈ 33 min) are shown honestly. (2) Sub-shard the single event's seats across many nodes by section so the hold load fans out horizontally instead of pounding one node — the whole point being that "shard by event" is not enough when one event is the load. (3) Push more work off the synchronous path: holds stay synchronous and atomic (correctness can't be deferred), but seat-map updates, analytics, and even payment capture go async behind queues so a payment-gateway slowdown can't back-pressure into seat contention. What I explicitly do not relax is the atomic hold — correctness is the one thing that must not bend at 100×; everything else (latency, fairness granularity, map freshness) is negotiable.

Q6 — "A buyer holds seats then vanishes. How do those seats come back, and how fast?"
The HELD row carries hold_expires_at = now()+5min. A background Expiry Worker scans holds where hold_expires_at ≤ now and flips them HELD→AVAILABLE (and signals the waiting room to promote the next buyer). Because every hold uses the same fixed TTL, insertion order equals expiry order, so the sweep is an ordered scan, not a full-table search. Worst-case a seat is stranded for 5 minutes + one sweep interval — tune the TTL down for hotter events to recycle inventory faster, at the cost of buyers who type their card slowly.

8. You can now defend


Re-authored/Deepened for this guide. Model-answer reading: Designing Ticketmaster and Designing Ticketmaster — Seat Reservation Without Double-Booking, Traced (System Design Problems). Concept pages: MVCC & Locking — row locks, SELECT FOR UPDATE, deadlocks (optimistic vs pessimistic); What Is Distributed Locking; Idempotency Keys for Payments; and Dispatch & Assignment: the double-booking race.

🤖 Don't fully get this? Learn it with Claude

Stuck on Design Ticketmaster? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.

🎨 Explain it visually

Build the mental picture, not memorization.

I just read a lesson on **Design Ticketmaster** (Hands-On Builds) and want to truly understand it. Explain Design Ticketmaster from first principles using ONE vivid real-world analogy and a visual mental model — draw it as ASCII art or a clear step-by-step diagram — with a concrete example using real numbers. Then ask me one question to check I got the mental picture, and wait for my reply. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🤔 Walk me through it (interactive)

Socratic — adapts to where you're stuck.

Teach me **Design Ticketmaster** interactively. Ask me ONE guiding question at a time, wait for my answer, and adapt to my confusion — build the idea with me step by step instead of explaining it all at once. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🧪 Quiz me & fix my gaps

Active recall exposes what you missed.

Quiz me on **Design Ticketmaster** with 5 questions, easy to tricky, ONE at a time. Tell me if each answer is right; at the end, explain clearly what I got wrong and why. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🧠 Make it stick

Intuition + hook + flashcards for long-term memory.

Help me remember **Design Ticketmaster** for the long term: give the one-sentence intuition, a memorable hook/mnemonic, a tiny worked example, and 3 active-recall flashcards (Q -> A). If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.

📝 My notes