Designing Ticketmaster
A movie-ticket booking system prevents two people from buying the same seat by making the "who wins this seat" decision exactly once, atomically: each contested seat lives as one row that a short serialized transaction locks at read time (SELECT ... FOR UPDATE), while the transient five-minute hold and the fairness queue live outside that transaction in per-show ordered maps. Everything else in the product — browsing cities, movies, showtimes, seat maps — is a read-scaling problem you solve with caches and a CDN. The entire design converges on one hotspot: the write path for a popular show, where thousands of requests fight over a few thousand seat rows in the same few seconds.
Requirements
Functional. List cities → movies in a city → cinemas and showtimes for a movie → seat map for a show. A user selects up to 10 seats and gets a hold for 5 minutes to pay. If seats are taken but may free up (other users' holds expiring), the user may wait, and waiting users are served first-come-first-serve. Bookings are all-or-nothing — no partial orders.
Non-functional. High concurrency on the same seat, handled fairly; ACID for the money path; scalable and available through on-sale traffic spikes.
Capacity estimate (why the write path is the whole game)
Assume 3B page views/month and 10M tickets sold/month. Page views average ≈ 3B / 2.6M s ≈ 1,150 reads/s, spiking maybe 10× on a hot on-sale — easily absorbed by cache + CDN + read replicas. Ticket sales average only ≈ 4 writes/s — trivially small on paper, but they are not uniform: a single blockbuster on-sale funnels a huge fraction of those writes into one show's few thousand seat rows within seconds. So capacity is comfortable; contention is the problem.
Storage: 500 cities × 10 cinemas × 2000 seats × 2 shows × (50+50) bytes ≈ 2 GB/day, ≈ 3.6 TB over five years — small enough that the DB is not the bottleneck; the lock contention on hot rows is.
APIs
SearchMovies(api_dev_key, keyword, city, lat_long, radius,
start_datetime, end_datetime, postal_code,
results_per_page, sorting_order)
-> [ { MovieID, ShowID, Title, StartTime, EndTime,
Seats: [ {Type, Price, Status} ] } ] // cacheable, read-only
ReserveSeats(api_dev_key, session_id, show_id, seat_ids[])
-> "Reservation Successful"
| "Reservation Failed - Show Full"
| "Reservation Failed - Retry" // seats currently held by otherssession_id is the handle the server uses to expire the hold if payment does not complete. Search is read-only and cache-friendly; ReserveSeats is the one call that touches the contended write path.
Data model
The relationships that matter: a Movie and a Hall each have many Shows; a Show has many Show_Seat rows (one per physical seat for that show, pre-created with Status ∈ {0 free, 1 reserved, 2 booked}) and many Bookings; a User has many Bookings. The key modelling choice is that seat availability is per-show state on Show_Seat, not on the physical seat — that row is the single object every concurrent request contends for.
High-level architecture and how a show is routed
Web servers hold user sessions; application servers do reservation logic and talk to an ACID SQL store (primary + secondary) and a cache. The key structural decision: all in-memory state for a given show — its active reservations and its waiting queue — must live on one owner. We route by consistent hashing on ShowID, so every request for Show 99 lands on the same app server, which is the only place its ordered maps exist. That is what makes fair FCFS ordering and O(1) hold-expiry possible without cross-node coordination on the hot path.
The booking mechanism: two ordered maps
The server keeps two per-show structures, each a Hashtable<ShowID, LinkedHashMap<…>>. A LinkedHashMap is chosen because it gives two properties at once for free:
- ActiveReservationsService —
LinkedHashMap<BookingID, ReservationEntry>. Because every hold uses the same fixed 5-minute TTL, insertion order equals expiry order, so the head is always the next reservation to expire — a background sweep just peeks the head, expires it if due, and pops, then sleeps until the new head'sexpiresAt. And when a booking completes (or the user cancels), you can jump straight to itsBookingIDand remove it in O(1) — a plain queue could not do this random removal, and a plain hash map could not give ordered expiry. - WaitingUsersService —
LinkedHashMap<UserID, waitStart>. Insertion order is arrival order, so the head is the longest-waiting user → serve them first (FCFS). O(1) removal handles a user who cancels or times out (max 1-hour wait).
Clients hold a long-poll open against WaitingUsersService; when seats free up the server completes that request to push the user back to the seat map. Whenever a reservation completes or expires, ActiveReservationsService signals WaitingUsersService so it can wake the head — if the freed seat count now meets that user's requested count. Note the trade-off this buys: strict FCFS can idle freed seats behind a large head request (head wants 5, only 3 freed, and the #2 waiter who wants exactly 2 stays asleep). The alternative — a bounded look-ahead that scans the first k waiters for a fit — recovers that utilization at the cost of strict fairness (small parties keep leapfrogging big ones).
Concurrency: making the decision once
The correctness core is a short transaction that takes an exclusive lock on the specific seat rows at read time, so a competing transaction blocks at its own SELECT instead of racing to the UPDATE:
BEGIN;
-- reserve seats 54,55,56 of ShowID=99; lock in ascending id order (deadlock-safe)
SELECT ShowSeatID
FROM Show_Seat
WHERE ShowID = 99
AND ShowSeatID IN (54, 55, 56)
AND Status = 0 -- 0 = free
ORDER BY ShowSeatID
FOR UPDATE; -- EXCLUSIVE row locks acquired NOW
-- app check: got exactly 3 rows?
-- yes -> proceed no -> ROLLBACK, return "retry / wait"
UPDATE Show_Seat SET Status = 1 -- 1 = Reserved
WHERE ShowSeatID IN (54, 55, 56);
INSERT INTO Booking (ShowID, UserID, Status, ExpiresAt)
VALUES (99, 4711, 1, CURRENT_TIMESTAMP + INTERVAL '5' MINUTE);
COMMIT; -- lock held for milliseconds, NOT the 5-minute payment windowWhy the naïve version is wrong. An earlier framing said "within a transaction, if we read rows we get a write lock on them." A plain SELECT does not. Under SERIALIZABLE in a lock-based engine (e.g. SQL Server) a plain read takes shared/range locks — two transactions can both read Status=0, then both attempt the UPDATE, deadlock on the lock upgrade, and the engine aborts one. Under PostgreSQL's SSI, a plain read takes no row locks at all; it detects the write-write conflict at commit and raises a serialization failure. Both are safe only via abort-and-retry, which collapses throughput and fairness on a hot row. SELECT ... FOR UPDATE takes the exclusive lock up front, so the loser blocks and then observes the truth (Status=1) — a clean fail, no retry storm. Because the seat rows already exist, you don't even need SERIALIZABLE; FOR UPDATE under READ COMMITTED is sufficient and cheaper (no range-lock overhead). Once the transaction commits, ActiveReservationsService starts tracking the hold in memory.
Worked trace: two users race for seats 54–56 of Show 99
Both Alice and Bob want the same three seats; the 5-minute TTL then expires Alice's unpaid hold and promotes Bob from the waiting queue.
| Time | Txn A (Alice) | Txn B (Bob) | Seats 54–56 | Maps / outcome |
|---|---|---|---|---|
| 14:00:00.000 | BEGIN; SELECT … FOR UPDATE → 3 rows, Status=0; X-locks held | — | free, locked by A | — |
| 14:00:00.002 | (updating) | BEGIN; same SELECT … FOR UPDATE → blocks on A's locks | locked | Bob parked on lock |
| 14:00:00.050 | UPDATE Status=1; INSERT Booking(ExpiresAt=14:05:00); COMMIT → locks freed | still blocked | Status=1 (Reserved) | ActiveReservations[99] head = B-8801 |
| 14:00:00.051 | done | SELECT unblocks → 0 rows with Status=0 → ROLLBACK; API returns "retry / wait" | Reserved | Bob appended to WaitingUsers[99] (head) |
| 14:05:00.000 | no payment | — | — | TTL sweep: head expiry ≤ now → Booking=Expired, Status=0, pop; signal waiting service |
| 14:05:00.010 | — | long-poll wakes: 3 free ≥ 3 wanted → sent to seat map | free | Bob promoted from waiting head |
No moment exists where both hold the seats: the lock serializes the decision, the hold lives as a row (not an open lock), and fairness comes from the queue ordering, not from luck.
Reservation expiration and crash recovery
Expiry. The client shows a countdown that can drift from the server clock. Add a server-side buffer of ~5 seconds so the server never expires a hold before the client's timer hits zero — otherwise a user who clicks "Pay" at 4:59 could have the seat yanked mid-purchase.
Settlement is a CAS, not an assumption. The grace buffer only shrinks that race window — it cannot close it: the TTL sweep (Booking→Expired, seat Status→0) and a completing payment can still interleave. So payment completion must itself be a conditional update, in one transaction: UPDATE Booking SET Status = Booked WHERE BookingID = ? AND Status = Reserved AND ExpiresAt > now(), flipping the seat rows to Status = 2 in the same transaction. If it reports 0 rows, the sweep won — void/refund the charge and tell the user; never mark the booking booked. Because sweep and settle both write the same Booking row with guarded updates, the database serializes them and exactly one wins — the same decide-once-atomically discipline as the seat grab itself. "Paid but expired" becomes a compensable event, never a double-owned seat.
Crash recovery. ActiveReservationsService is a cache over durable truth: after a crash, rebuild each show's map by reading Booking rows with Status=Reserved and their ExpiresAt. WaitingUsersService is not persisted, so a crash silently drops everyone waiting — mitigate with a primary-secondary (hot standby) replica, or accept that waiters simply re-poll. Run the SQL store itself primary-secondary for durability.
Partitioning
Partition the database by ShowID, not MovieID: a blockbuster's shows would otherwise all land on one shard and hot-spot it, whereas ShowID spreads a movie's shows across shards. Use consistent hashing on ShowID to assign the app servers that own a show's maps (say 3 replicas per show). On expiry, the owning server updates the DB, removes the map entry, notifies the expired user, and broadcasts to the WaitingUsersService replicas for that show so the longest waiter is woken. On a successful full booking, it tells those replicas to expire any waiter whose requested count now exceeds the seats left.
Pitfalls
- Holding the transaction open for the payment window. The classic beginner bug:
BEGIN … FOR UPDATE …then wait 5 minutes for the user to pay. That pins a DB connection and locks rows for minutes, so the connection pool drains and the show freezes. The hold must be aStatus + ExpiresAtrow; the lock is held only for the millisecond flip. - Retry storms from plain SELECT under SERIALIZABLE. Correct but slow — hot rows generate a flood of serialization aborts. Use explicit
FOR UPDATErow locks so losers block-then-observe instead of abort-then-retry. - Deadlock on multi-seat bookings. If request A locks seats (54,55) and request B locks (55,54), they deadlock. Always lock seats in a canonical order (ascending
ShowSeatID) — hence theORDER BYin the SQL above. - Thundering herd on release. Broadcasting a freed seat to every waiter and letting them all re-attempt re-creates the contention. Wake only the head (and only if the freed count fits its request).
- Volatile waiting state. WaitingUsers lives only in memory; a crash or a consistent-hashing rebalance orphans it. Replicate it or make waiters idempotently re-register.
- Client/server clock skew. Without the server-side grace buffer, a user's payment succeeds on the client but the seat is already expired server-side — a broken, angering experience.
When to use this design — and when not
This design is application-managed timed holds + pessimistic row locking at commit. It is the right fit when contention is moderate-to-high but not extreme, you need a fair FCFS waitlist, timed holds so a human can finish paying, an interactive seat map, and strict ACID on money. Reach for it for cinemas, mid-size venues, and most "reserve then pay" flows.
- vs. pure optimistic concurrency (version column / compare-and-set, no hold): cheaper and lock-free, great at low contention. But on a hot show nearly every write collides → constant retries, no fairness, and no natural way to express a 5-minute hold. Prefer it when collisions are rare (e.g. general-admission with plenty of inventory).
- vs. distributed lock (Redis SETNX / Redlock) per seat: very fast for the transient hold and offloads the DB. But Redis isn't the money source of truth, lock-expiry races the payment window, and correctness under failover is contentious. Use Redis for the ephemeral hold if you still commit the sale transactionally in SQL — not as the sole arbiter.
- vs. queue-based serialization (per-show Kafka partition, single consumer decides seats): eliminates lock contention by making each show's decisions single-threaded, and scales to millions of simultaneous buyers. The cost is added latency, eventual-consistency in the UI, and operational complexity. This is the Flash Sale design — prefer it when a single on-sale (a stadium headliner) drives a write rate that row locks simply cannot serialize in time.
Choose THIS when you need fair timed holds + an interactive seat map at moderate contention with ACID money; prefer queue-based serialization when one show's peak write rate on its seat rows outstrips what pessimistic locking can push through, and prefer optimistic concurrency when contention is genuinely rare.
Takeaways
- The product is 95% a read-scaling problem (cache + CDN + replicas); design effort belongs on the one write hotspot — contested seat rows on a hot show.
- Decide the seat once, atomically:
SELECT ... FOR UPDATEin a millisecond-long transaction — never a plainSELECT(that's abort-and-retry, not mutual exclusion), and never an open transaction across the payment window. - The five-minute hold is a
Status + ExpiresAtrow, not a held lock; insertion-ordered maps then give O(1) TTL expiry (reservation head) and O(1) FCFS fairness (waiting head) simultaneously. - Durable truth lives in SQL (rebuild reservations after a crash); the in-memory maps are a fast cache plus a volatile waitlist that needs replication to survive failure.
Re-authored and deepened for this guide. Problem framing from Grokking the System Design Interview ("Design Ticketmaster"). Concurrency corrected against the PostgreSQL documentation on transaction isolation and Serializable Snapshot Isolation, the Microsoft SQL Server transaction-isolation-levels reference, and Martin Kleppmann, Designing Data-Intensive Applications, ch. 7 (two-phase locking, predicate/range locks, SSI, and SELECT ... FOR UPDATE). The traced companion page 018 uses the same FOR UPDATE pattern.
Operability and drill ladder
Ops signals: alert on the held-seat ratio (reserved ÷ total for a show), the TTL-expiry sweep lag (how far behind "now" the head of ActiveReservations is running), and — the one that should sit at ≈0 — payment successes arriving after the server-side hold already expired (each one is caught and voided by the settle CAS's 0-rows branch), which flags a clock-skew or grace-buffer regression.
Drill ladder
- L1: Write the FOR UPDATE seat claim SQL with ORDER BY.
- L2: Why is the hold not an open transaction?
- L3: Deadlock two multi-seat carts — prevent how?
- L4: Redis SETNX hold vs SQL truth — failure mode on Redis failover.
- L5: Flash-sale alternative architecture in 5 bullets.
🤖 Don't fully get this? Learn it with Claude
Stuck on Designing Ticketmaster? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.
Build the mental picture, not memorization.
I just read a lesson on **Designing Ticketmaster** (System Design) and want to truly understand it. Explain Designing 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.
Socratic — adapts to where you're stuck.
Teach me **Designing 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.
Active recall exposes what you missed.
Quiz me on **Designing 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.
Intuition + hook + flashcards for long-term memory.
Help me remember **Designing 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.