Design Ticketmaster
Sell 60 000 seats in ten minutes to a million people at once: hold a seat while someone pays, never sell it twice, and keep the site up when demand is a hundred times supply.
Last updated 2026-09-22. Difficulty: hard. Patterns: inventory, reservations, locking, queueing, thundering-herd. Reported at Amazon, Stripe, Uber, Airbnb, Booking.com, Shopify.
Sit this as an AI interview and be asked it one question at a time; Study shows every answer, and Practice hides them until you have produced your own.
Functional requirements
- Browse events and see what is available. Event pages, seat maps, price tiers. Read traffic is a hundred times the write traffic and must not be served from the inventory database.
- Hold a seat while the buyer checks out. A selected seat is exclusively reserved for a bounded time — ten minutes — then returns to the pool automatically if payment does not complete.
- Never sell the same seat twice. The one invariant that cannot bend. Overselling a flight is a refund; overselling seat 14C is two people standing in the same aisle.
- Take payment and issue a ticket. Payment is a slow external call that can succeed after we have given up waiting, so the hold and the payment must be reconciled rather than assumed.
- A waiting room for on-sales. When a million people arrive at 10:00 for 60 000 seats, admit them in a fair, visible order rather than letting everyone hammer the checkout path.
- General admission as well as reserved seating. Some events sell a count, not a seat map. The same reservation machinery has to handle "any 4 of 5 000" without serialising on one row.
- Out of scope. Dynamic pricing, resale and transfer, fraud scoring, and the physical access control at the venue gate.
Non-functional requirements
- Correctness of inventory (zero double sales). Strong consistency on the seat. Everything else in the design may be eventually consistent; this may not.
- On-sale peak (1 M users, 100 k RPS reads). Demand arrives in a single second, not a ramp. The architecture is judged entirely on this minute.
- Checkout latency (p99 < 500 ms to hold a seat). A buyer who waits three seconds to hold a seat assumes it failed and clicks again, doubling the load at the worst moment.
- Availability of browse (99.99 %). The event page must stay up even if the inventory write path is degraded, because most traffic is looking rather than buying.
- Fairness (first-come order within a second). Perfect global ordering is neither achievable nor expected; visible, explicable ordering is.
- Hold expiry accuracy (± 5 s). A seat released late is lost revenue; a seat released early while someone is typing their card number is a furious customer.
- Payment reconciliation (no lost or double charges). Every payment must end in exactly one of: ticket issued, or money returned.
Back-of-envelope estimates
- On-sale arrival rate: ~1 M in 60 s. A stadium tour on-sale draws ~1 M people in the first minute — roughly 17 k arrivals/s, each loading a page and then polling. This is the number that sizes the waiting room, not the seat count.
- Read requests per second at peak: ~100 k/s. 1 M users each making ~6 requests in the first minute (page, seat map, availability polls) = ~100 k/s. Nearly all of it is the same handful of responses, so it is a caching problem rather than a database problem.
- Reservation writes per second: ~1 k/s. 60 000 seats sold over ten minutes = 100/s of successful holds, but failed attempts on already-held seats are several times that: ~1 k/s of write attempts. Two orders of magnitude below reads, which is why the write path can afford to be strict.
- Seat map size: ~2 MB raw, ~50 KB delta. 60 000 seats × ~30 B of (id, section, row, price tier, status) = ~2 MB. Sending that to every one of a million clients is 2 TB; sending a compressed status bitmap and then deltas is ~50 KB then a few hundred bytes.
- Held seats at any moment: ~10 k. 100 successful holds/s × a 10-minute expiry, plus abandonment, gives ~10 k seats in the held state at the peak of a large on-sale. Small enough to keep the hold state in memory, large enough that expiry has to be a real mechanism.
- Payment call duration: ~2 s p50, up to 30 s. A card authorisation is 1–3 s normally and can take 30 s with 3-D Secure. The hold window must comfortably exceed the tail, which is why it is ten minutes and not sixty seconds.
- Storage per year: ~1 TB. 50 k events/year × 20 k tickets average × ~1 KB of order, ticket and audit rows = ~1 TB/year. Tiny. This is not a storage problem, it is a concurrency problem.
Components
- Browser / app: Shows the seat map, holds a countdown while the buyer pays, and re-renders availability from deltas. Written to tolerate being told "that seat went" at any moment, because at an on-sale it usually did.
- CDN: Serves the event page, the static seat map geometry and a periodically refreshed availability snapshot. Absorbs the great majority of on-sale traffic; the origin should barely notice the first second.
- Waiting room (edge worker + queue): Admission control at the edge. Everyone arriving at an on-sale gets a signed queue token and a position; only a metered trickle is admitted to the purchase path. Nothing else in the design survives an on-sale without it.
- API tier: Validates the queue token, terminates sessions, and routes reads to the cache and writes to the reservation service. Stateless and horizontally scaled.
- Reservation service: Owns the one invariant. Turns "hold these seats" into a conditional write, sets the expiry, and refuses anything that is not currently available. Sharded by event so one hot on-sale cannot starve the rest of the catalogue.
- Inventory DB (Postgres, partitioned by event): One row per seat with status, hold token and expiry, plus a count row per GA tier. The authority. Every transition is a conditional update, so correctness does not depend on anything above it behaving.
- Hold index (Redis · sorted set by expiry): Live holds ordered by expiry, so the sweeper can ask "what expired?" in one range query instead of scanning the seat table. A cache of state the database still owns, so losing it costs efficiency, not correctness.
- Availability cache (Redis · bitmap per event): A bitmap of seat status per event, updated from the change stream and served as a compressed blob plus deltas. Deliberately a second or two stale: the seat map is a hint, and the reservation is the truth.
- Order service: Drives checkout as a state machine: held → authorising → paid → ticketed, or → released. Idempotent on an order key, because the client will retry and the payment provider will send the same webhook twice.
- Payment provider: Third party, slow, and occasionally tells you about a success long after you stopped waiting. Every design decision about holds exists because of this box.
- Expiry sweeper: Releases holds whose expiry has passed, in small batches, and republishes availability. Also the backstop for orders stuck mid-flight.
- Event bus (Kafka, partitioned by event id): Every inventory transition is published: hold, release, sale. Feeds the availability cache, the ticket issuer, analytics and the waiting-room admission rate.
- Ticket issuer: Mints the ticket and its rotating barcode after payment settles, emails it, and pushes it to the wallet. Downstream of the sale, so its latency never delays checkout.
User flows
- A buyer picks a seat and pays for it. The happy path, and the one that defines the state machine: hold first, pay second, confirm third. Never the other order.
- Buyer opens the event page; the seat map and a recent availability snapshot come from the CDN.
- Buyer selects two seats; the client asks to hold them.
- The reservation service claims both seats with a conditional update.
- The hold is indexed by expiry and published; the client starts a visible countdown.
- Buyer submits payment; the order service authorises against the provider.
- Authorisation succeeds; the order confirms the hold, turning it into a sale.
- The ticket issuer mints tickets from the sale event and delivers them.
- Forty people click the same seat. One wins, thirty-nine are told instantly and cheaply, and the database does thirty-nine trivial no-ops rather than forty fights.
- The stale availability bitmap still shows A-12-3 as free to everyone who loaded the page in the last two seconds.
- Forty hold requests arrive within the same second.
- The first conditional update wins; the other thirty-nine match zero rows.
- Losers get an immediate, specific rejection with the seats that are still free nearby.
- The sale is published and the availability bitmap updates within a second or two for everyone else.
- A million people arrive at 10:00:00. The scale-breaking case. The answer is not to scale the purchase path to a million concurrent users but to refuse to let a million users into it.
- Everyone hits the event URL in the same second; the edge serves the page and puts them in the queue.
- Admission is metered against how fast the purchase path is actually completing.
- Admitted users are handed a short-lived purchase token and reach the API.
- The queue position is shown and moves honestly.
- Inventory is exhausted; the queue is drained with a clear message rather than left spinning.
- A hold expires, or a payment is slow. The failure path that decides whether seats leak. Every hold must end in a sale or a release, with no third option.
- A buyer holds two seats and abandons the tab.
- The sweeper queries the expiry index for holds past their deadline.
- Each expired hold is released with a conditional update.
- Meanwhile a payment authorised at the provider after we gave up waiting.
- The order tries to confirm, finds the seats gone, and refunds immediately.
- The release is published and the seats reappear in the availability bitmap.
- General admission: any 4 of 5 000. No seat rows to contend on. A naive counter serialises the whole sale on one row, so the count is split.
- A buyer asks for four GA tickets in the standing tier.
- The tier count is split into buckets and the request takes from one.
- A buyer whose bucket is empty falls through to another bucket rather than failing.
- The hold, the payment and the confirmation are the same machinery as reserved seating.
- Released GA holds return to the bucket they came from.
Deep dives
- How a seat is held. Database row lock, a distributed lock, a status column with an expiry, or an in-memory reservation service? A status column per seat — available, held, sold — with hold_id and expires_at, mutated only by conditional UPDATEs inside a single transaction per request. Multi-seat holds are all-or-nothing in one transaction. A sweeper releases expired holds every second using a Redis sorted set as an index, and every state transition is also guarded by the expiry so a late sweep can never steal a seat from a confirming order.
- The waiting room. How do you serve a million simultaneous arrivals for 60 000 seats? An edge waiting room that issues a signed position token to every arrival and admits a metered trickle, with the rate driven by observed sale completions and the current held-seat count. Position polling is served entirely at the edge from the token and a published watermark. Positions are bound to an authenticated account so extra tabs do not improve your odds, and when remaining inventory drops below the admitted population, admission stops and the queue is told honestly.
- Serving availability to a million viewers. How fresh does the seat map need to be, and how do you serve it? A per-event status bitmap maintained by a single consumer of the inventory event stream, published to the availability cache and served through the CDN with a two-second TTL and stale-while-revalidate. Open pages receive compact deltas — the seats that changed since a version — over a shared stream, falling back to polling the versioned snapshot. The client always treats availability as advisory and the hold response as authoritative, and the seat map says when it was last updated.
- Money and inventory in two systems. How do you avoid charging someone for a seat they do not get, or giving a seat away for free? An order state machine — created → authorising → paid → ticketed, with released and refunded as terminal alternatives — persisted before any external call and driven by idempotency keys on both the client request and the provider webhook. Authorisation is refused if the hold has less time left than the payment tail requires. Confirmation is conditional on the hold still being valid, and a reconciliation job sweeps orders stuck in authorising, queries the provider for the truth, and either completes or refunds them.
- Partitioning inventory when one event is everything. How do you shard when a single event can be 90 % of the day's load? Partition inventory by event id, so every hold, confirm and release is a single-partition transaction. Maintain a routing table from event to partition that can be changed per event, and move a scheduled high-demand on-sale onto dedicated capacity before it opens, returning it afterwards. Reads never touch these partitions at all — they are served from the availability cache — so the partitions are sized purely for the write path.