Design Ticketmaster: Selling 100k Seats Without Overselling One
How to design Ticketmaster for a Taylor Swift on-sale — seat holds, virtual waiting rooms, capacity math, and the two-phase lock that prevents overselling.
This article has an interactive companion
Don't just read it — step through it interactively, one state change at a time.
At 10:00:00 AM a Taylor Swift tour goes on sale. By 10:00:03, half a million people are hammering the same page for the same 100,000 seats. Two click seat 14F at the same millisecond. Exactly one can win — let both win and you've sold a chair that doesn't exist.
That race, multiplied across a stadium and buried under a 100x load spike, is what makes Ticketmaster a favorite senior system-design question. It's not about storing events; it's about correctness under brutal contention — no double-sells, no lost inventory on a failed payment, no backend that melts when the drop begins.
This walks the standard rubric — requirements, capacity, architecture, API, data model, then the deep dives that decide the outcome. Practice this topic on YeetCode alongside the reading.
Functional requirements: what the system must do
Scope the problem before drawing a single box:
- Browse events and see live seat availability, updating as seats sell.
- Hold a specific seat while the buyer enters payment details.
- Purchase held seats, turning a hold into a confirmed booking.
- Release inventory automatically if checkout is abandoned or payment fails.
Out of scope for a 45-minute interview: dynamic pricing, resale, refunds, event-creation tooling. The middle two bullets are the hard part — everything here exists to make "hold then buy" safe under contention.
Non-functional requirements and capacity estimation
The non-functional constraints are where this lives or dies:
- Correctness over availability for writes — a seat sells at most once, even at the cost of latency.
- Massive read/write asymmetry — browsing outnumbers buying by orders of magnitude.
- Extreme burstiness — minute one runs ~100x baseline load.
Numbers: 100,000 seats across 50 events, 500,000 concurrent users at peak.
| Quantity | Estimate | How it's derived |
|---|---|---|
| Edge request rate | ~1M RPS | 500k users × ~2 page actions/sec |
| Booking write QPS | ~5–10k writes/sec | gated by the waiting room, not raw demand |
| Seat inventory size | ~50 MB | 100k rows × ~500 bytes — fits in RAM |
| Concurrent holds | ~50k holds ≈ 50 MB | ~1 KB per hold, ~10k hold-writes/sec |
| Confirmed bookings | ~170/sec sustained, 10x bursty | 100k seats sold over ~10 minutes |
| Seat-map egress | ~100 GB | 500k users × ~200 KB map payload |
Two conclusions drive the design: inventory is tiny (50 MB fits in Redis or a Postgres page cache), so the bottleneck is lock contention, never storage; and 100 GB of egress is a read problem the CDN must absorb.
High-level architecture
The system splits into a read plane that scales horizontally and a write plane that must be strictly correct.
┌──────────────┐
users ───────▶│ CDN / Edge │ seat-map snapshots (cached)
│ + Waiting │ admission tokens (HMAC-signed)
│ Room worker │
└──────┬───────┘
│ admitted users only
┌──────▼───────┐ ┌───────────────┐
│ Booking svc │─────▶│ Inventory DB │ (event_id, seat_id)
│ (hold/buy) │ │ Postgres │ conditional UPDATEs
└──────┬───────┘ └───────────────┘
│ saga
┌──────▼───────┐ ┌───────────────┐
│ Payment svc │ │ Pub/Sub fanout│──▶ WebSocket
│ (idempotent) │ │ (Redis/Kafka) │ seat deltas
└──────────────┘ └───────────────┘The waiting room admits users only as fast as the backend can safely take writes. The booking service owns the two-phase seat lock. Pub/sub fanout streams seat-state changes to watchers, and the payment service runs hold-to-sold as a saga so a failed charge never strands inventory.
API design
Keep the surface small and make state transitions explicit.
GET /events/{id}/seats → snapshot + version cursor
POST /events/{id}/holds → { seat_ids } → hold_id, expires_at
POST /bookings/{hold_id}/confirm → { payment_token } → booking, receipt
DELETE /holds/{hold_id} → release early (optional)
WS /events/{id}/stream → live (seat_id, state, version) deltasThe snapshot's version cursor lets the client apply WebSocket deltas idempotently. The hold endpoint returns an expires_at so the UI can show a countdown. Confirmation carries the hold_id because ownership is proven by matching that id, not by trusting the caller.
Data model
Five entities cover the domain. The subtle move is materializing inventory per event, not per venue:
| Entity | Key fields | Notes |
|---|---|---|
| Event | id, venue_id, start_time, status | one row per show |
| Venue | id, seat_map_version | reusable seating chart |
| Seat inventory | (event_id, seat_id), state, hold_id, expires_at | the row you lock |
| Booking | id, user_id, event_id, state, expires_at | HOLD → CONFIRMED / EXPIRED |
| Payment | booking_id, provider_ref, state | one per booking attempt |
Pre-generate one inventory row per (event_id, seat_id) from the venue's seat map when the event is created — hence 100k rows, not one shared seat table. Locking on (event_id, seat_id) means a Taylor Swift row-lock never contends with a hockey game selling the same physical seat on a different night.
The Booking state machine is the spine: born in HOLD with a short expires_at, moved to CONFIRMED on payment or EXPIRED on timeout. A per-user cap on simultaneous holds keeps one account from tying up a whole section without ever paying.
How do you prevent two people from buying the same seat?
The answer is a two-phase optimistic lock — never a long-running transaction.
Phase one, the hold, is a single conditional update:
UPDATE seat_inventory
SET state = 'HELD', hold_id = :hold, expires_at = now() + interval '8 minutes'
WHERE event_id = :event AND seat_id = :seat AND state = 'AVAILABLE';
-- rows affected: 1 = you won the seat, 0 = someone beat youThe WHERE state = 'AVAILABLE' clause is the entire correctness argument. Two concurrent requests run this statement; the database serializes them on the row lock, the first affects one row, the second affects zero. No application-level coordination, no distributed consensus — just an atomic compare-and-set the database already gives you.
Front this with a Redis SETNX as a fast optimistic gate if you like, with Postgres as the source of truth — but the conditional UPDATE is what makes overselling impossible, so it stays authoritative.
Phase two, confirmation, is a second conditional update gated on the hold id:
UPDATE seat_inventory
SET state = 'SOLD'
WHERE event_id = :event AND seat_id = :seat
AND state = 'HELD' AND hold_id = :hold;Because confirmation checks hold_id, a retry from a crashed worker can't confirm a seat some other buyer already picked up. Reclaiming expired holds uses two mechanisms: a periodic sweep flips stale rows back to AVAILABLE, and any read past expires_at treats the row as free immediately.
Note what's rejected: wrapping selection, payment, and confirmation in one long transaction — the FAQ below covers why that's a trap under load.
The virtual waiting room: surviving the spike
Even a perfect seat lock dies if a million requests per second hit it directly. The fix: admission control at the edge — a virtual waiting room that queues users before they touch the booking service.
The edge worker issues each user a signed queue token with a position and an admit_at timestamp, then releases people at a rate matched to backend capacity — checkout handles 2,000 writes/sec, you admit 2,000/sec, no more. That rate isn't fixed: it's tied to live health checks on the booking service, so a strained database tightens the release valve automatically.
Two details matter: shuffle users within the same arrival bucket so mashing refresh gives no advantage, and bind each token to user plus session, HMAC-signed, so nobody forges a front-of-line spot. This turns "500k users, unbounded" into "2k/sec, sized" — the only condition under which the seat lock stays correct.
Deep dives: live seat maps, bots, and payment failures
Three more problems reliably come up once the core is solid.
Live seat-map updates. Thousands watch one event and expect seats to grey out the instant they sell. Re-sending the full 200 KB map on every change doesn't scale, so split it: an HTTP snapshot loaded once with a version number, plus a live stream of tiny updates over WebSocket or SSE. A messaging layer (Redis Pub/Sub, NATS, or Kafka) fans updates out per event_id as small (seat_id, state, version) records; clients apply only what's newer. Batch into ~100ms windows and give a hot event its own gateway shard so it can't starve the rest.
Bots and scalpers. No single control stops determined scalpers, so stack several: fingerprint low-level connection signals (TLS handshake shape, header ordering, timing) to cluster non-browser traffic; layer a silent risk score that tightens as contention rises; cap tickets per account and per card; and, counterintuitively, don't hard-block traffic you're fairly sure is automated — reroute it into a slower queue. A block tells the operator to rotate a proxy and retry; a slowdown burns their time.
Payment failure after a hold. This is a saga, not a distributed transaction — hold, charge, confirm are separate steps, each with a defined undo. Tag the payment intent with the booking's own id as its idempotency key, so a retried charge after a network blip is recognized as the same request rather than billed twice. A failed charge flips the seat back to available; a worker dying mid-flight needs no cleanup, since the hold's TTL frees the seat once it lapses. Write each state change to a local outbox row and let a relay forward it to Kafka, so downstream consumers (receipts, loyalty crediting) stay eventually consistent without a distributed transaction.
How this shows up in interviews
Interviewers use Ticketmaster to test whether you reach for correctness primitives instead of hand-waving "we'll use a lock." Expect three moments: the conditional UPDATE and why its WHERE clause prevents overselling, holds over a long transaction, and sizing the waiting room against real QPS. Weak candidates describe a happy path; strong ones lead with failure modes.
Further learning
- Design Ticketmaster w/ Ex-Meta Staff Engineer — Hello Interview's whiteboard walkthrough of this exact problem.
- Design a Payment System — the idempotency-key and saga patterns behind confirmation.
- Design a Search Engine — a read-heavy plane scaling independently of writes.
- Design Top K Problem — ranking hot events at scale.
- Design a Proximity Service — venue lookups powering discovery.
FAQ
Why materialize seat inventory per event instead of per venue?
The unit of contention is a seat for a specific show, not the physical chair. Lock at the venue level and a Taylor Swift on-sale would fight a Tuesday hockey game over the same seat number for no reason. One row per (event_id, seat_id) — 100,000 for this drop — scopes every lock to exactly one show, at a trivial ~50 MB cost.
How long should a seat hold last?
Long enough for a human to enter payment details, short enough that abandoned checkouts don't strand inventory — roughly 8 minutes is a common choice, shown as a countdown. It doubles as a crash-recovery timeout: a worker that dies mid-payment leaves a hold that lapses on its own.
What does the virtual waiting room actually protect against?
A demand spike that can run 100x above steady state. Instead of letting a million requests per second slam the seat-lock path directly, the edge admits users only as fast as the backend can safely take writes — say 2,000/sec — and dials that down if the database starts to strain. That turns unbounded demand into a sized, steady stream, the only condition under which the seat lock stays correct.
How does the system avoid double-charging when a payment is retried?
By binding an idempotency key to the booking id, so any retry — a network blip, a restarted worker — is recognized as the same request rather than billed twice. The path runs as a saga: success confirms the booking, failure releases the hold, and a crashed worker needs no cleanup since the hold's own TTL frees the seat.
Why is a single ACID transaction per booking a bad idea?
It would hold row locks for the entire minutes-long span of a human typing card details, tying the database's availability to a third-party payment provider's response time — exactly the lock contention an on-sale can't afford. The two-phase hold instead splits the work into two short transactions with the slow payment call sandwiched between them, outside any lock, and the hold's TTL caps the worst case.
Make it stick: run this one yourself
Don't just read it — step through it interactively, one state change at a time.