8. Ticketmaster (Event Ticket Booking) Advanced + High-Contention

A. Realistic Interview Prompt

"Design a ticket booking platform like Ticketmaster: users browse events, select seats, and purchase tickets — for events where demand can vastly exceed supply the instant tickets go on sale."

This is the sharpest strong-consistency problem in this document. Every prior design leaned eventually-consistent almost everywhere, with at most one narrow atomic island (Rate Limiter's counter, Uber's driver assignment). Here, the entire product is built around a single unforgiving invariant — a seat can be sold at most once — combined with a traffic pattern that is the opposite of steady: near-zero demand, then an instantaneous, massive spike the second a popular on-sale opens. Both properties push hard against the "just scale it out and eventually-consistent everything" instincts most of the rest of this document teaches.

B. Difficulty

Advanced, and specifically high-contention — the naive design (J) isn't wrong about the data model, it's wrong about concurrency, and that flaw is invisible until two people click "buy" on the same seat within milliseconds of each other, which is precisely what happens at scale during a real on-sale.

C. What This Problem Tests

D. Requirements

Functional

  1. Users can browse events and view seat availability
  2. Users can select one or more specific seats (or general-admission quantity) and reserve them temporarily while completing checkout
  3. Users can complete payment to convert a temporary reservation into a confirmed, sold ticket
  4. A reservation that isn't completed within a time limit is released back to availability

Non-Functional

Out of scope: event creation/venue-management tooling, pricing/dynamic pricing, refunds/resale marketplace.

E. Clarifying Questions

#QuestionWhy It MattersDesign Decision Affected
1Specific seat selection, or general-admission quantity-only?Specific-seat selection is a strictly harder concurrency problem — many individually contested resources instead of one decrementing counterCore Entities (H)
2How long should a seat be held once selected, before checkout must complete?Directly sets the reservation-hold TTL, a central parameter of the whole designDeep Dive 2 (N)
3Is the traffic pattern a known, scheduled on-sale time, or unpredictable organic demand?A known on-sale time is exactly what a virtual waiting room is designed to defend against — a materially different mitigation than general rate limitingDeep Dive 4 (P)
4Should displayed seat-availability counts be exact in real time, or is a brief lag acceptable?Exact-in-real-time display would require the same strong consistency as the sale itself; a brief lag decouples display load from the critical reservation pathDeep Dive 6 (R)
5Do we need to defend against bots/scalpers buying disproportionate inventory?A real, product-relevant concern for high-demand events, requiring active countermeasures beyond basic rate limitingDeep Dive 7 (S)

F. Assumptions

G. Capacity Estimation

MetricValue / CalculationWhy It Matters
Venue size (illustrative on-sale)50,000 seatsThe actual number of contested resources — small relative to the request volume competing for them
Concurrent requests at on-sale open1M+ within the first few seconds (illustrative, matching known real-world popular on-sale behavior)A ~20:1 (or far higher) ratio of demanding requests to available seats — the core scale challenge is contention, not steady-state throughput
Reservation hold duration5-10 minutes (F's assumption)Bounds how long an abandoned reservation ties up a seat before automatic release (N)
Peak reservation-attempt QPSA large fraction of the 1M concurrent requests will attempt a reservation within seconds — tens of thousands of reservation attempts/sec, illustratively, against only 50,000 total seatsConfirms the reservation step itself, not the browse/display path, is the system's true bottleneck and highest-risk component
Seat/inventory data size50,000 seats × ~200 bytes (seat_id, section, status, hold expiry) ≈ ~10MB per eventTrivially small — confirms this is a concurrency problem, not a storage-volume problem, unlike almost every other problem in this document
Events at this scale/yearA modest number of "hot" on-sales/year across a platform, versus a much larger long tail of low-contention eventsJustifies treating extreme contention as a per-event, on-demand mode (P) rather than the platform's permanent steady state

H. Core Entities

EntityPurposeKey Fields
EventA ticketed event at a venueevent_id, venue_id, on_sale_at
SeatOne specific, individually contested seat for one eventseat_id, event_id, status (available/held/sold), hold_expires_at, held_by
Reservation (ephemeral)A user's in-progress hold on one or more seatsreservation_id, user_id, seat_ids, expires_at, status
OrderA confirmed, paid purchaseorder_id, user_id, seat_ids, payment_ref, confirmed_at

I. API Design

GET  /v1/events/{id}/seats               -> [{ seat_id, status (approx, R) }]
POST /v1/events/{id}/reservations        { seat_ids } -> { reservation_id, expires_at }   (atomic hold, M)
POST /v1/reservations/{id}/confirm       { payment_token } -> { order_id }                (Q)
DELETE /v1/reservations/{id}                                                              (explicit early release)
POST /v1/events/{id}/waiting-room/join   -> { queue_token }                               (P, only for flagged high-demand events)

Why a distinct Reservation resource between "browsing" and "confirmed order," not a direct seat-to-order purchase call: payment processing itself takes real time and can fail for reasons unrelated to seat availability (a declined card) — collapsing "hold the seat" and "charge the card" into one step would mean either holding the seat for an unpredictably long time during payment processing, or risking the seat being sold to someone else mid-payment. Splitting them into an atomic hold (M) followed by a separate confirm step (Q) is what makes both halves individually tractable.

J. Basic / Naive High-Level Design (V1)

The simplest thing that satisfies the functional requirement in isolation: a seats table with a status column; reserving a seat means reading its status, checking it's available, then updating it to held — as two separate database calls.

flowchart LR U(["User selects a seat"]) --> API["Booking Service"] API -- "1. SELECT status WHERE seat_id" --> DB[("Seats Table")] DB --> API API -- "2. if available,
UPDATE status = held" --> DB

V1 — a two-step read-then-write. Correct-looking, but the gap between steps 1 and 2 is exactly where two users can collide.

Why this breaks immediately, not eventually: at real on-sale contention (G's 1M+ concurrent requests against 50,000 seats), the gap between "read status" and "write status" — even a few milliseconds — is a window where two different requests for the same seat can both read "available" and both proceed to write "held," resulting in two users believing they've reserved the identical seat. This is the exact check-then-act race named explicitly in Rate Limiter (N) and Uber (P), just with a legally/financially binding outcome instead of an approximate count or a delayed match — and unlike those two problems, there is no acceptable "eventual" fallback here; a double-sold seat is a direct, visible, and expensive product failure.

K. Data Model & Store Choice

Why a relational database, not an eventually-consistent NoSQL store, for seat inventory specifically: this is the one entity in the entire document where ACID transactional guarantees and row-level locking are exactly the right tool, not an unnecessary constraint — a relational database's native support for a single atomic conditional update (UPDATE ... WHERE status='available', checking the affected-row count) or a short transaction with a row lock directly implements the correctness this problem needs, without inventing bespoke atomicity machinery the way Rate Limiter's Lua script or Uber's CAS had to.

Sharding by event_id: all seats for a given event are always accessed together (browsing, reserving, holding) and never need to be joined against another event's seats — a natural, clean shard key that also happens to contain each hot event's contention to its own shard, so one viral on-sale's load doesn't spill over onto the rest of the platform's traffic.

Reservation hold TTLs: tracked either as an expiry timestamp column checked lazily on read, or via a fast in-memory store (Redis) with native key expiry for immediate, proactive release — a hybrid is common in practice: the relational row is the source of truth for the seat's actual status, while a TTL-based cache entry drives prompt automatic release without needing a polling job.

L. End-to-End Flow — Seat Reservation to Confirmed Order

sequenceDiagram participant U as User participant BS as Booking Service participant DB as Seat DB (atomic hold, M) participant Pay as Payment Service U->>BS: reserve(seat_ids) BS->>DB: atomic conditional UPDATE (available -> held) alt hold succeeds for all seats DB-->>BS: success BS-->>U: reservation_id + expires_at U->>BS: confirm(reservation_id, payment_token) BS->>Pay: charge payment alt payment succeeds Pay-->>BS: success BS->>DB: UPDATE held -> sold, create Order BS-->>U: order confirmed else payment fails Pay-->>BS: declined BS->>DB: UPDATE held -> available (release early) BS-->>U: payment failed, seat released end else any seat already held/sold DB-->>BS: conflict BS-->>U: seat(s) unavailable, choose again end

M. Deep Dive 1 — Preventing Overselling

Requirement this addresses: directly fixes V1's fatal flaw (J) — the single highest-signal deep dive in this entire problem.

The race, explicitly: two requests for the same seat arrive within milliseconds; a naive two-call read-then-write lets both believe they succeeded. The fix must collapse "check" and "act" into one indivisible operation.

MechanismHowTrade-off
Atomic conditional update (chosen default)UPDATE seats SET status='held' WHERE seat_id=? AND status='available', checking the affected-row count — the database's own row-level locking makes the check-and-write indivisibleSimple, uses standard relational-database guarantees directly, no bespoke coordination primitive needed — the most defensible default answer
Pessimistic row lock (SELECT ... FOR UPDATE)Explicitly lock the row for the duration of a transaction spanning the check and the writeEqually correct, but holds a lock for longer, which can worsen contention under extreme load if the transaction does anything slow inside it
Distributed lock (e.g. Redis-based)Acquire an external lock keyed by seat_id before touching the database at allUseful if the "hold" logic spans multiple systems, but adds an entirely new failure mode (lock-service unavailability) for a problem the database's own atomicity already solves cleanly

Why the plain atomic conditional update is the right default, not the distributed lock: the same principle as choosing a Lua script over an external coordination service in Rate Limiter (N) — reach for the storage layer's own native atomicity primitive first; only introduce an external distributed lock if the operation genuinely needs to span systems the database transaction can't reach, which a single seat's status update does not.

Multi-seat reservations specifically: reserving several seats at once (a group of friends booking together) must succeed or fail as a single unit — wrapping the conditional updates for all requested seats in one database transaction ensures a user is never left holding 3 of 4 requested seats because one was contested away mid-request.

N. Deep Dive 2 — Reservation Holds & Expiry

Requirement this addresses: the temporary-hold functional requirement (D) — a seat marked "held" that's never released because a user abandoned checkout would slowly starve inventory even for a moderately popular event.

Why a bounded, automatically-expiring hold, not an indefinite one: per F's assumption, holds last 5-10 minutes — long enough for a real checkout flow, short enough that an abandoned cart doesn't meaningfully impact overall availability. Two release triggers work together: explicit release (the user backs out or a payment fails, per L) and automatic expiry (the TTL simply elapses with no confirmation).

Why a TTL-driven release (K's Redis-backed expiry), not a polling job scanning for expired holds: a periodic sweep introduces both latency (a seat might sit incorrectly "held" for up to one sweep interval past its actual expiry) and unnecessary database load scanning rows that are, most of the time, not yet expired. Native key expiry triggers the release essentially immediately when the TTL elapses, without any polling overhead — a materially better fit for a time-bounded resource than a scheduled scan.

O. Deep Dive 3 — Surviving Flash-Sale-Scale Load

Requirement this addresses: the extreme-spike NFR from D and G — 1M+ concurrent requests against 50,000 seats is a fundamentally different load shape than this document's other read-heavy problems, where load is large but comparatively smooth.

Why this isn't primarily a "scale out more servers" problem: the bottleneck isn't application-tier compute, it's contention on a small, fixed set of database rows — adding more booking-service instances just means more concurrent attempts to conditionally update the same 50,000 rows, which doesn't by itself reduce collisions, and can actually increase database-side lock contention if unbounded.

P. Deep Dive 4 — The Virtual Waiting Room

Requirement this addresses: directly extends O — a deliberate admission-control strategy specific to known, scheduled traffic spikes (E's clarifying question 3), distinct from general rate limiting (which reacts to load, rather than anticipating it).

flowchart LR U(["Users arriving
at on-sale time"]) --> WR{"Virtual Waiting Room"} WR -- "assign queue position/token" --> Hold["Hold in queue
(polling or WebSocket for position updates)"] Hold -- "admitted in controlled batches" --> Booking["Booking flow (M)"] WR -.-> Reject["No booking-service load
from users still queued"]

The waiting room's entire job is to prevent the 1M+ concurrent surge from ever reaching the contested booking path at once.

Why this is a deliberate design choice, not a workaround for an under-scaled system: even a perfectly-scaled booking service still has only 50,000 seats to sell — admitting all 1M+ simultaneous requests into the reservation flow at once wouldn't let more people successfully book, it would just mean ~950,000 requests fail the atomic conditional update (M) after having already consumed real capacity getting there. A waiting room controls the rate of admission into the contested path, smoothing the exact same total demand into a shape the reservation system can process without the database layer being hit by the full spike instantaneously.

Fairness mechanism: typically a randomized or arrival-order queue token issued at (or shortly before) the on-sale time, not raw first-byte-received ordering — raw network-arrival-order ordering would systematically favor users with lower latency/better connections or automated tooling, which directly motivates naming this alongside the bot-mitigation discussion in S.

Q. Deep Dive 5 — Payment Integration Timing

Requirement this addresses: the confirm-purchase functional requirement (D) — the second half of the two-phase commitment introduced in I's API design.

Why payment happens strictly after the seat hold succeeds, never before or concurrently: charging a payment before confirming the seat is actually available risks charging a user for a seat that gets contested away before the charge even completes; attempting both simultaneously reintroduces exactly the kind of race M was built to eliminate. Sequencing them — hold first (fast, purely internal), charge second (slower, involves an external payment processor) — means the slow, externally-dependent step only ever happens once the seat is already safely, atomically held under that user's reservation.

Idempotency on the confirm step: a client retrying a confirm call after a timeout (without knowing whether the first attempt actually succeeded) must not risk double-charging the user — the confirm endpoint accepts an idempotency key so a retried request with the same key returns the original result rather than re-attempting the charge, the same idempotent-retry principle used for client_msg_id in the Chat System problem, here protecting a financial transaction instead of a chat message.

R. Deep Dive 6 — Seat Map Display & Search

Requirement this addresses: the browse functional requirement (D), under the approximate-display assumption from F — this is the read path, and it must be decoupled from the strongly-consistent reservation path (M) to keep the vastly larger "just browsing" traffic from ever touching the contested seat rows.

Why the displayed seat map can lag reality slightly: per E's clarifying question 4, a seat map that's a few seconds stale (occasionally showing a seat as available that was actually just reserved by someone else) is a minor, self-correcting UX hiccup — the user simply gets a "sorry, that seat was just taken" response from the real, strongly-consistent check in M when they actually attempt to reserve it. A cache (Redis, or a read replica of the seat table) serving the browse/seat-map view absorbs the overwhelming majority of read traffic without ever touching the primary seat table that M's atomic updates depend on.

Search/discovery (events by artist, venue, date) is a standard, fully separable search-index concern — the same async-indexing pattern used for search in the News Feed, Chat System, and Video Streaming problems, entirely decoupled from seat-level inventory state.

S. Deep Dive 7 — Fairness & Bot/Scalper Mitigation

Requirement this addresses: the fairness NFR from D — a real, product-relevant concern for high-demand events, where automated tools attempting to buy disproportionate inventory directly undermines the fairness the waiting room (P) is trying to provide.

Why this is worth naming even though it's not the problem's core mechanic: a design that flawlessly prevents overselling but has no defense against a single automated actor acquiring a large fraction of available seats has technically satisfied D's correctness requirement while missing the fairness requirement entirely — worth stating explicitly as a distinct, product-relevant concern rather than assuming "no double-booking" is the whole story.

T. Full Solution at Scale — Final Architecture

flowchart TB U(["User"]) --> Browse["Browse / Seat Map (R)
served from cache/replica"] U -- "on-sale traffic" --> WR["Virtual Waiting Room (P)"] WR -- "admitted, rate-controlled" --> BS["Booking Service"] subgraph SEATDB [" Sharded Seat DB, by event_id (K) "] direction LR Shard1[("Event A shard")] Shard2[("Event B shard")] end BS -- "atomic conditional update (M)" --> SEATDB BS -- "hold TTL (N)" --> TTLStore[("Redis TTL Store")] BS -- "hold confirmed" --> Pay["Payment Service (Q)
idempotent charge"] Pay -- "success" --> SEATDB Pay -.-> Order[("Order Store")] BS -.-> Guard["Bot/Fairness Guard (S)
rate limits + purchase caps"]

Everything above is the composition of M through S — no new component introduced here.

U. Design Evolution

What stays stable throughout: the reserve → confirm two-step contract from I — every version underneath changes without the client needing to change its checkout flow shape.

V. Consistency Model

The most strongly-consistency-weighted design in this entire document: the seat-hold step (M) requires genuine strong consistency — there is no acceptable eventual-consistency fallback for "is this seat available," unlike every prior problem's narrower strong-consistency islands. The browse/seat-map display (R) is the one deliberately eventually-consistent piece, explicitly to keep the much larger read volume off the strongly-consistent path — a brief display staleness is harmless because the real check happens at reservation time regardless of what the display showed. This is close to the inverse of URL Shortener's model (eventual everywhere, one narrow strong-consistency island for uniqueness) — here strong consistency is the default for anything touching actual inventory, and eventual consistency is the narrow, deliberate exception carved out only for display.

W. Failure Scenarios

X. Bottlenecks & Scaling Boundary

StageActual BottleneckFix Applied
V1 two-call read-then-writeA check-then-act race allows overselling under any real concurrencyAtomic conditional update / row lock (M)
Atomic updates, no shard isolationOne viral event's contention can degrade booking for unrelated events sharing infrastructureSharding by event_id (K, O)
Sharded, but full surge hits the DB directly1M+ concurrent requests overwhelming a shard even with atomicity in placeVirtual waiting room admission control (P)
Controlled admission, but browse traffic still hits the seat tableThe much larger "just looking" volume competing for the same rows as real reservation attemptsCached/replica-backed browse path (R)

Y. Design Trade-Offs

DecisionOption AOption BTrade-off
Overselling preventionApplication-level distributed lockDatabase atomic conditional update (chosen)A distributed lock adds a whole new failure mode for a guarantee the database's own transactional semantics already provide cleanly and more simply
Hold releasePeriodic polling sweep for expired holdsTTL-driven expiry (chosen)Polling is simpler to reason about but adds latency and unnecessary scan load; TTL expiry releases almost immediately with no polling overhead
Extreme-spike handlingScale booking-service compute onlyVirtual waiting room admission control (chosen)More compute doesn't reduce contention on a fixed set of rows; a waiting room controls the rate of admission into the contested path directly, which compute scaling alone cannot do
Payment sequencingCharge before/alongside the holdHold first, then charge (chosen)Charging before the hold risks charging for a seat that gets contested away; sequencing makes the slow, external payment step happen only once the seat is already safely held
Seat-map displayRead directly from the live seat tableCached/replica-backed, slightly stale (chosen)Reading live guarantees perfect accuracy but puts the largest traffic volume on the most contended table; a cached view accepts brief staleness to fully decouple the two paths

Z. Common Bad Designs

AA. Interviewer Follow-Ups

Follow-UpDesign ChangeComponents ChangedComponents Unchanged
"Two users reserve overlapping seats in a group booking at the exact same moment."Directly exercises M's transaction-wrapped multi-seat atomic update — succeeds or fails as one unitNone — this is exactly what M already handlesEverything
"Support general-admission (quantity-only, no specific seat) tickets too."The contested resource becomes a decrementing counter rather than N individual seat rows — the same atomic-conditional-update principle applies, just against a count instead of a row per seatInventory model in H/KWaiting room, payment sequencing, hold TTLs
"How do you stop scalper bots from buying half the venue?"Directly exercises S's purchase-limit and bot-mitigation layerNone — this is exactly what S already handlesCore reservation/payment flow
"Add a resale/secondary marketplace."A meaningfully new sub-system (re-listing a sold ticket) built on top of the existing Order entity, out of this design's core scope per DNew marketplace service consuming Order dataPrimary sale flow entirely
"The waiting room itself is getting hammered at on-sale time."The waiting room's own admission-token issuance is a much simpler, higher-throughput operation than an actual seat reservation — scale it independently, since it doesn't touch the contested seat rows at allWaiting-room service scaling (P)Seat DB, atomicity guarantees

AB. Interviewer Evaluation Rubric

AC. Interview Simulation

Interviewer: Design a ticket booking system like Ticketmaster — users pick seats and buy tickets, including for events where demand is enormous.
Candidate: The core correctness requirement is that a seat can never be sold to more than one person. The naive approach — check if a seat is available, then mark it reserved, as two separate calls — has a race: two requests can both see "available" in that gap and both proceed. I'd fix that with an atomic conditional update at the database level: update the seat's status to held, but only where it's currently available, and check whether the update actually affected a row.
Interviewer: Why not use a distributed lock instead?
Candidate: A lock service adds a whole new thing that can fail, for a guarantee the database already gives you natively through row-level locking or a conditional update. I'd only reach for an external lock if the hold logic needed to span systems the database transaction can't reach — a single seat's status doesn't need that.
Interviewer: Once a seat is held, when does payment happen?
Candidate: Strictly after the hold succeeds, never before or at the same time — charging first risks charging someone for a seat that gets taken by someone else before the charge finishes. And the confirm step needs an idempotency key, since a client might retry it after a timeout without knowing if the first attempt actually went through, and I don't want to risk double-charging.
Interviewer: This event has 50,000 seats and a million people try to buy at the exact on-sale second. What happens?
Candidate: That's not really a "scale out the servers" problem — there are still only 50,000 seats no matter how much compute you add, so letting all a million requests hit the reservation path at once just means most of them fail after consuming real capacity getting there. I'd put a virtual waiting room in front of the booking flow specifically for known high-demand on-sales — it issues queue positions and admits people into the actual reservation flow in a controlled, rate-limited way, so the database layer never sees the full spike instantaneously.
Interviewer: How do you keep that fair?
Candidate: I wouldn't base the queue purely on raw network arrival time, since that favors bots and low-latency connections over genuine buyers. I'd add some human-verification at waiting-room entry and per-account purchase limits, alongside the seat-level atomicity — correctness alone doesn't guarantee fairness, so I'd treat that as its own explicit concern.

AD. Online Solution Comparison

SourceApproachStrengthWeaknessWorth Learning
Hello InterviewAtomic-reservation-first framing, explicit two-phase hold/confirm discussion, staff-level virtual-waiting-room treatmentClearest articulation of the overselling race and the hold/confirm split as the two central ideas — the core structure adopted for this sectionLighter on the specific bot/fairness mitigation angleThe two-phase commit framing (hold, then charge) reused directly as the spine of L and Q
ByteByteGoDetailed database-locking mechanism comparison (optimistic vs. pessimistic) and sharding-by-event discussionThe most rigorous side-by-side treatment of locking strategies of any source reviewedLess depth on the waiting-room's fairness-token mechanics specificallyThe pessimistic-lock-vs-conditional-update comparison table adapted into M
donnemartin/system-design-primer (GitHub, general concurrency patterns)General distributed-locking and race-condition-avoidance patterns applicable directly to high-contention inventory problemsGood grounding in the general check-then-act race vocabulary reused consistently across this document (Rate Limiter, Uber, here)Not ticket-booking-specific — requires translating general concurrency advice into the seat-inventory domainConfirming the check-then-act race is a recurring, well-known distributed-systems pattern, not a problem-specific quirk
Real-world virtual waiting room vendors (e.g. Queue-it — industry practice, reviewed at a conceptual level)Actual production admission-control services used by real ticketing platforms during high-demand on-salesGround-truth confirmation that a dedicated waiting-room layer is standard real-world practice, not a hypothetical interview constructVendor marketing material, not a system-design walkthrough — needs translation, which P providesConfirming that admission control as a deliberate, separate layer (not just rate limiting) is how real platforms handle exactly this problem

AE. Interview Cheat Sheet

ProblemTicketmaster (Event Ticket Booking)
DifficultyAdvanced + High-Contention (the sharpest strong-consistency requirement in this document)
Core requirementsBrowse, reserve, pay, auto-release; no overselling ever; 50K-seat venue, 1M+ concurrent on-sale requests, fast checkout, fair admission
Central decisionAtomic conditional update / row-level lock for the seat hold — the database's own transactional guarantees, not a bespoke or external lock
Purchase flowTwo-phase: atomic hold first, idempotent payment charge second — never charge before or during the hold
Hold expiryTTL-driven automatic release (no polling), bounded to a short checkout window
Extreme spike handlingVirtual waiting room as deliberate admission control — not a workaround, since more compute can't create more seats
Browse/displayCached/replica-backed, deliberately eventually consistent — kept off the strongly-consistent reservation path entirely
FairnessHuman-verification + per-account purchase limits + rate limiting, layered alongside (not instead of) seat-level atomicity
Common mistakesTwo-call check-then-write races; charging before holding; treating spikes as a pure compute-scaling problem; no hold expiry; ignoring fairness/bots
What NOT to over-engineerA full resale/secondary marketplace or dynamic pricing engine — name them as separate, out-of-scope systems built on top of the Order entity