Index/Ticketmaster / Booking

SponsorGitHub
Design16 min

Ticketmaster / Booking

Fifty thousand people wanting the same hundred seats in the same second.

Primary concepts and the hard part

Concepts
pessimistic vs optimistic lockingdistributed locks with TTLextreme contention on a small datasetvirtual waiting roomsadmission controlsagascache-vs-truth divergence
The hard part they’re probing
This is a contention problem, not a scale problem. 50,000 people all want the same 100 seats in the same second. Row-level lock contention, not throughput, is what breaks. Candidates who apply feed-system instincts (cache everything, fan out, eventual consistency) fail this one.

Requirements

Functionalwhat it must do
Browse events, view seat availability
Select specific seats, hold them temporarily while checking out
Complete purchase; hold expires and releases if abandoned
Support both reserved seating and general admission
Cancellations and refunds
Non-functionalhow well it must do it
Never double-sell a seat. Hard invariant.
Handle massive spikes: near-zero traffic, then 100x for 60 seconds at on-sale
Fair-ish access; not purely "fastest network wins"
Availability display can be slightly stale; the purchase cannot be
Out of scopedeliberately left out
Dynamic pricing
Bot detection specifics (mention it matters enormously)
Secondary market
Scale
Normal traffic:        1,000 req/sec
On-sale spike:         100,000 req/sec for ~60s   ← the design driver
Seats per big event:   50,000
Concurrent buyers:     500,000 chasing 50,000 seats (10:1 oversubscription)
Conclusion
The dataset is tiny (50,000 rows) and the traffic is enormous and bursty. This inverts every normal instinct: you don't need sharding or a big cluster, you need admission control to keep 500,000 people from touching 50,000 rows simultaneously.

API / Model

GET
/v1/events/{id}
200OKevent + availability summary
GET
/v1/events/{id}/seats?section=
cached, may be stale
200OKseat map
POST
/v1/events/{id}/holds
Idempotency-Key {seat_ids[]}
409 Conflict when a seat is already held
201Created409Conflicthold_id, expires_at
POST
/v1/holds/{id}/checkout
Idempotency-Key {payment_token}
201Createdbooking_id
DELETE
/v1/holds/{id}
release early
204No Content
GET
/v1/queue/status
200OKwaiting room position
primary keyforeign key → referenced columnnullableHover a table or column to trace its keys

The state field on seats plus a version column is the whole concurrency-control story. Everything else is supporting cast.


High-level architecture

DatabaseCacheQueue / streamExternal systemAPI gatewayFocusClick a node for details

Buyers reach the application only through the virtual waiting room, which queues arrivals in a Redis sorted set under signed queue tokens and admits them at a controlled rate, about 1,000 per second, each for roughly 10 minutes. Behind the API Gateway, admitted users browse a deliberately stale Redis seat map and book against the Primary DB, which is sharded by event.

  1. With a valid queue token, the API Gateway passes a hold request to the booking path. The transaction locks the chosen seats with SELECT … FOR UPDATE, taking the locks in sorted order. If every seat is still AVAILABLE, it marks them HELD with held_by and expires_at, inserts the hold and commits. If any seat is gone, it rolls back and returns 409 with nearby alternatives.
  2. Within the 10-minute hold, checkout runs as a saga that verifies the hold, charges the buyer, marks the seats SOLD and issues tickets.
  3. Every seat change in the Primary DB is published to Kafka seat.events, which invalidates the cached seat map and pushes live seat updates over WebSocket.

The browse path never takes a lock. Seat maps come from the CDN as static pages and from the Redis seat map, which has a TTL of 2-5 seconds and is refreshed by those seat events. Beside the booking path, the hold expiry sweeper scans the Primary DB and returns seats from expired holds to AVAILABLE.


Trade-offs and deep dives

01
The waiting room is the most important component

Without it, 500,000 concurrent requests hit 50,000 rows and the database dies on lock contention — not on CPU, on waiting. The waiting room converts an uncontrolled stampede into a controlled admission rate matched to what the booking tier can actually process. It also gives users an honest experience (a queue position and ETA) instead of an error page, and it makes the system's load predictable rather than a function of how popular the event turned out to be.

This is admission control from Module 8, and recognizing that it belongs before the application tier rather than inside it is the senior insight.

02
Pessimistic vs optimistic locking — choose and justify
text
PESSIMISTIC (SELECT ... FOR UPDATE)
  Lock the rows, then check and update. Other transactions block.
  ✓ No wasted work, no retry storms
  ✓ Correct under heavy contention          ← the case here
  ✗ Holds locks; risk of deadlock if lock order varies

OPTIMISTIC (version column, compare-and-swap)
  UPDATE seats SET state='HELD', version=version+1
   WHERE seat_id=? AND version=? AND state='AVAILABLE'
  Check rows-affected: 0 means someone beat you.
  ✓ No locks held, great when conflicts are RARE
  ✗ Under 10:1 oversubscription, almost everyone loses and retries
    → retry storm makes contention worse

For a hot on-sale, pessimistic wins, because conflicts are the norm rather than the exception and optimistic retries amplify load exactly when you can least afford it. Optimistic is fine for ordinary, low-contention bookings. Being able to say "which one depends on the conflict rate, and here the conflict rate is enormous" is the answer they're looking for.

03
Deadlock prevention

When holding multiple seats, always acquire locks in a consistent order (sort seat IDs). Two transactions grabbing seats A and B in opposite orders will deadlock. Sorting eliminates the cycle. Small detail, real bug, good signal.

04
Holds need a TTL, and expiry must be checked twice

A hold that never expires means an abandoned checkout permanently removes a seat from sale. Set hold_expires_at, run a sweeper to reclaim expired holds and check expiry at read/hold time. Relying solely on the sweeper means a brief window where an expired hold still blocks a sale; relying solely on read-time checks means expired holds linger in the data. Do both.

05
The cache is deliberately stale, and that's correct

The seat map shown while browsing is a cached snapshot with a 2-5 second TTL. It will sometimes show a seat that was just taken. That is acceptable and unavoidable — any attempt to make the browse view perfectly accurate under 100k req/sec will destroy the database. The contract with the user is: the map is a hint, the truth is decided when you press "hold," and a 409 at that point is a normal, expected outcome that the UI must handle gracefully. Stating this boundary between "eventually consistent display" and "strongly consistent transaction" is the core trade-off of the design.

06
Shard by event

All contention for one event lands on one partition. That sounds bad, but it's actually the goal: it means a hot on-sale for one stadium show cannot degrade every other event on the platform. Blast-radius containment. Since the dataset per event is tiny, one partition handles it comfortably once the waiting room caps the arrival rate.

07
General admission is a different problem

No seat map, just a counter. Use an atomic decrement (UPDATE ... SET sold = sold + 1 WHERE sold < total) or a Redis counter with a conditional. Much cheaper, no row-level contention, and worth distinguishing from reserved seating explicitly — they often ask about both.

08
Checkout is a saga

Charge payment, mark seats sold, issue tickets. If payment fails, release the hold. If ticket issuance fails after a successful charge, do not release seats — retry issuance, because the customer has paid. Deciding which failures compensate and which retry is the substance of saga design, and it's worth walking through rather than just saying "saga."

09
Bots

At a real on-sale, most of that 500,000 is automated. Defences: queue tokens tied to authenticated accounts, device fingerprinting, per-account purchase caps enforced at hold time, CAPTCHAs on entry to the queue rather than at checkout (where they'd cost you real conversions), and rate limits per account. Mention it — a Ticketmaster design that ignores bots is missing the actual production problem.


Possible follow-up questions

7 questions·try answering before you reveal