SysDesignPrep.com
System design interview question

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 and 5 more with Pro.

Walk through a strong candidate's answer, turn by turn.

The interviewer asks, the candidate answers and draws, and you press Next. Pause to answer yourself at the key decisions, and ask the coach anything along the way.

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

  1. 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.
    1. Buyer opens the event page; the seat map and a recent availability snapshot come from the CDN. Geometry is static and cached for a long time. The status bitmap is cached for a second or two, which is honest: by the time it renders, some of those seats are gone.
    2. Buyer selects two seats; the client asks to hold them. Both seats are held in one call and one transaction: holding them separately can leave a buyer with one of the two they wanted, which is worse than failing.
    3. The reservation service claims both seats with a conditional update. One transaction: UPDATE seats SET status=held, hold_id=…, expires_at=now()+10min WHERE event_id=… AND seat_id IN (…) AND status=available. If it updates fewer rows than requested, the whole transaction rolls back and the buyer is told which seat went. The database, not the application, is what makes double-selling impossible.
    4. The hold is indexed by expiry and published; the client starts a visible countdown. The countdown matters as much as the mechanism: a buyer who can see they have nine minutes does not panic-refresh, which is the behaviour that takes sites down.
    5. Buyer submits payment; the order service authorises against the provider. The order carries the hold id and its expiry. If the hold has under thirty seconds left the order service refuses to start an authorisation it cannot finish, and asks the buyer to re-select: better than taking money for a seat that is about to be released.
    6. Authorisation succeeds; the order confirms the hold, turning it into a sale. Conditional again: sold only if the row is still held under this hold id. If the sweeper released it in the meantime, the order refunds immediately rather than issuing a ticket for a seat someone else now holds.
    7. The ticket issuer mints tickets from the sale event and delivers them. Asynchronous by design. The buyer sees a confirmation immediately; the barcode can arrive seconds later, and a slow email provider never holds a seat hostage.
  2. 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.
    1. The stale availability bitmap still shows A-12-3 as free to everyone who loaded the page in the last two seconds. Unavoidable and fine. The alternative (a strongly consistent seat map for a million viewers) would cost more than the tickets are worth.
    2. Forty hold requests arrive within the same second. They land on the shard that owns that event. Requests for one seat naturally serialise on one row, which is the cheapest correct thing that can happen.
    3. The first conditional update wins; the other thirty-nine match zero rows. No explicit locking, no retry loop, no distributed lock service: the WHERE clause is the lock, and a losing request costs one index lookup. Row-level contention on a single seat is bounded by how many people can click it.
    4. Losers get an immediate, specific rejection with the seats that are still free nearby. Returning alternatives in the same response is the difference between a buyer trying again once and a buyer refreshing thirty times. Error handling here is a load-shedding strategy, not a UX nicety.
    5. The sale is published and the availability bitmap updates within a second or two for everyone else. One consumer writes the bitmap; a million readers read it from cache and CDN. Fan-out of a seat change is O(1) writes, not O(viewers).
  3. 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.
    1. Everyone hits the event URL in the same second; the edge serves the page and puts them in the queue. The waiting room runs at the edge, so the origin never sees the spike. A queue token is a signed cookie carrying position and issue time: no server-side session for a million people.
    2. Admission is metered against how fast the purchase path is actually completing. Closed-loop control, not a fixed rate: the sale rate from the bus and the current hold count set the admission rate. If checkout slows, admission slows, and the site degrades into a longer queue instead of an outage.
    3. Admitted users are handed a short-lived purchase token and reach the API. The token is bound to a session and expires in a few minutes, so a leaked or shared position cannot be resold or reused. Every write endpoint requires it.
    4. The queue position is shown and moves honestly. Position polls are served entirely at the edge from the token plus a published admission watermark: no per-user state. A visible, monotonically improving position is what stops people opening six tabs, which is the behaviour that doubles the load.
    5. Inventory is exhausted; the queue is drained with a clear message rather than left spinning. When remaining inventory falls below the admitted-but-not-yet-purchased population, stop admitting and tell the rest. Leaving 800 000 people queueing for zero seats is the worst outcome of the day and is entirely avoidable.
  4. 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.
    1. A buyer holds two seats and abandons the tab. Roughly a third of holds are abandoned at a big on-sale. Those seats are the difference between selling out and not, so expiry is a revenue mechanism, not just hygiene.
    2. The sweeper queries the expiry index for holds past their deadline. A sorted set scored by expiry answers "what is due" in one range query. It runs every second in small batches so releases are smooth rather than arriving in bursts.
    3. Each expired hold is released with a conditional update. Released only if the row is still held under that hold id and still expired. A hold that was confirmed a millisecond earlier is not touched: the condition, not the timing, is what makes the race safe.
    4. Meanwhile a payment authorised at the provider after we gave up waiting. The webhook is the one source of truth about money. It will arrive late, more than once, and out of order, so the order service is idempotent on the provider's event id.
    5. The order tries to confirm, finds the seats gone, and refunds immediately. This is the case worth stating out loud in an interview: money can be taken for a seat we can no longer deliver, and the only acceptable resolution is an automatic refund with a clear message, never a ticket for a seat someone else holds.
    6. The release is published and the seats reappear in the availability bitmap. Returning seats late in an on-sale produces a second, smaller rush. Worth smoothing the release rate for exactly that reason.
  5. 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.
    1. A buyer asks for four GA tickets in the standing tier. There is nothing to pick, so every buyer contends for the same resource: a count. Decrementing one row at 1 k/s is a hot row, and at 10 k/s it is the whole system's throughput ceiling.
    2. The tier count is split into buckets and the request takes from one. Fifty buckets of 100 turn one hot row into fifty warm ones, chosen by hashing the session. A conditional decrement (WHERE remaining >= 4) keeps the invariant per bucket, and the invariant across buckets follows because the total is fixed at creation.
    3. A buyer whose bucket is empty falls through to another bucket rather than failing. Try a couple of buckets, then fall back to a scan of buckets with stock. Near sell-out most buckets are empty, so the last few hundred tickets are slower and more contended: acceptable, because by then the outcome is decided.
    4. The hold, the payment and the confirmation are the same machinery as reserved seating. Deliberately: one state machine for both inventory shapes, differing only in what "claim" means. Two checkout flows would be two chances to get the money path wrong.
    5. Released GA holds return to the bucket they came from. Returning to the original bucket keeps the sum right without a rebalancing job. A slow drift towards imbalance is corrected by the fallback scan, not by moving stock around.

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 hold is a lease: exclusive, bounded in time, and revocable. The question is where that lease lives and what happens when the holder disappears, which they will, constantly, because users close tabs.

The temptation is a distributed lock (Redis, ZooKeeper). It is the wrong instinct here: the inventory database already provides atomic conditional writes, and adding a lock service means correctness now depends on two systems agreeing instead of one.

  • Status column with hold id and expiry, updated conditionally chosen
  • SELECT … FOR UPDATE around read-modify-write rejected
  • Distributed lock service (Redis / etcd) in front of the database situational: the inventory store genuinely cannot do conditional writes, such as a legacy system behind a service
  • In-memory reservation service, single owner per event situational: extreme events where database write latency genuinely becomes the ceiling, with a replicated log behind it

The answer: 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 sweeper releases a hold at the same moment the order confirms it. Who wins?

Whoever the database serialises first, and both outcomes are safe because both are conditional. If the release lands first, the confirm matches zero rows and the order refunds. If the confirm lands first, the release matches zero rows and does nothing. The bug people write is an unconditional release, which can steal a seat from a completed payment.

Why not just make the hold window sixty seconds so expiry barely matters?

Because the payment tail is longer than that (3-D Secure alone can take thirty seconds) and a hold that expires mid-payment produces exactly the "charged but no ticket" case you are trying to avoid. Ten minutes is a compromise between locking up inventory and the length of a real checkout. The right instinct is to size the window from the payment latency distribution rather than from a round number.

Redis holding the expiry index dies during an on-sale. What breaks?

Nothing that matters. The index is an optimisation; expires_at in the durable row is the truth. The sweeper falls back to a scan for expired holds (slower and less frequent, so seats return a bit late) and the index is rebuilt from the database. That is the test of whether you put the invariant in the right place.

How do you stop a bot holding a thousand seats?

The hold path is authenticated and behind the queue token, so it is rate-limited per account and per token, with a cap on concurrently held seats per buyer: typically the same as the per-order ticket limit. This is a policy problem layered on the reservation machinery, not a change to it.

The waiting room

How do you serve a million simultaneous arrivals for 60 000 seats?

Demand exceeds supply by more than ten to one, in one second. No amount of horizontal scaling makes a million concurrent checkouts sensible: most of them cannot succeed, and letting them try converts a sell-out into an outage.

The insight is that admission control is a product feature, not a technical hack. A visible, fair queue is a better experience than a fast site that returns errors, and it makes the rest of the system sizeable to the sale rate rather than the arrival rate.

  • Edge waiting room with signed tokens and closed-loop admission chosen
  • Pure rate limiting, no queue rejected
  • Lottery: register in advance, randomly select buyers situational: extreme-demand events where the on-sale race is itself the problem, as many artists now prefer
  • Scale the purchase path to the arrival rate rejected

The answer: 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.

How do you pick the admission rate?

From the bottleneck you actually have: successful holds per second plus abandonment, against the number of seats left. Treat it as a control loop with the queue depth of the reservation service as the error signal. Fixed rates are wrong because checkout speed varies during a sale: it slows as the good seats go and buyers deliberate longer.

Someone shares their queue link. What happens?

Nothing useful for them. The token is signed and bound to a session and an account, and single-use on admission. Sharing gets the recipient a validation failure. Without that binding the queue becomes a market in positions, which has happened to real systems.

Is the queue strictly first-come-first-served?

Within about a second, yes; globally, no, and claiming otherwise is a mistake. Arrivals land at different edge locations with their own clocks, so ordering is by issue time within a bucket and arbitrary within a bucket. Users accept "you arrived in the same second as 30 000 others" much better than a queue that visibly jumps around.

What do the 940 000 people who get nothing see?

A clear sold-out message as soon as the remaining inventory falls below the admitted population, not a spinner that runs for twenty minutes. Draining the queue deliberately is part of the design, and it is the part most often left out.

Serving availability to a million viewers

How fresh does the seat map need to be, and how do you serve it?

Reads outnumber writes a hundred to one, and they are almost all the same response. Serving the seat map from the inventory database would put a hundred thousand queries a second onto the system that must stay correct: the one place you cannot afford contention.

The reframe: availability is a hint, and the reservation is the truth. Once you accept a stale seat map, it becomes a static-asset problem, which is a solved one.

  • Bitmap snapshot in cache, served via CDN, plus deltas chosen
  • Query the inventory database per page view rejected
  • WebSocket push of every seat change to every viewer situational: small, high-value events (a few thousand concurrent viewers) where per-seat liveness is worth it
  • Poll a versioned availability endpoint situational: the fallback for clients that cannot hold a connection, which is why the endpoint exists anyway

The answer: 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.

Two seconds stale means people keep clicking sold seats. Is that acceptable?

At an on-sale, yes, and no freshness budget fixes it: with ten times more buyers than seats, most selections were doomed the moment they were rendered. What matters is that the failure is instant, specific and offers alternatives. Making the map perfectly fresh would cost enormously and change the outcome for almost nobody.

How do you keep the bitmap and the database from drifting?

The bitmap is derived from an ordered event stream partitioned by event id, so it converges; and it is periodically rebuilt from the database as a backstop. Each snapshot carries the stream offset it reflects, so a stuck consumer is visible as a lagging offset rather than as quietly wrong data.

A 60 000-seat map is 2 MB of geometry. Do you send that to every phone?

Once, cached hard, and split by section so a buyer loads the sections they are looking at. Geometry never changes for an event, so it is a static asset with a long TTL; only the status bitmap is dynamic. Conflating the two is the common mistake and it is what makes seat maps slow.

Where does search ("cheapest four seats together") run?

Against the cached bitmap plus the static geometry, not the inventory database: it is a read-only computation over a few KB, so it can run at the edge or even in the client. The result is a set of candidate seats that the buyer then tries to hold, and the hold is where truth is applied.

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?

The payment provider is external, slow, and asynchronous. You cannot put it in a transaction with the inventory database, so there is no way to make "seat sold" and "money taken" atomic. Every correct design accepts this and reconciles instead.

That leaves exactly two acceptable end states for every attempt: a ticket issued and money taken, or no ticket and no money. Anything else is an incident.

  • Hold, then authorise, then confirm: with idempotency and reconciliation chosen
  • Charge first, then allocate a seat rejected
  • Two-phase commit across payment and inventory rejected
  • Saga with explicit compensations situational: once checkout involves several independent inventories (seats, parking, merchandise) in one transaction

The answer: 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.

The webhook never arrives. How does the order resolve?

The reconciliation job finds orders that have been authorising beyond a threshold and asks the provider directly, using the idempotency key as the lookup. Webhooks are an optimisation for latency; polling is the guarantee. A design that depends on the webhook arriving will lose money the first time the provider has an incident.

A client retries the order POST three times. How many charges?

One. The idempotency key is stored with the order before the provider is called, so a retry returns the existing order and its state. The key is also passed to the provider, which deduplicates on its side as a second line of defence. Both layers matter: ours covers a crash before the call, theirs covers a crash during it.

Payment succeeds but the seat was released. What does the buyer see?

An immediate automatic refund and a message that names the seat and offers to put them back in the flow, with priority if the queue is still live. The refund is initiated by the confirm failure, not by a human. The rate of this outcome should be on a dashboard, because a rise in it usually means the hold window is too short for the current payment latency.

How do you handle a partial failure on a four-seat order?

You do not allow one: holds are all-or-nothing in a single transaction, and the order references one hold covering all four seats. Confirming three of four and refunding one is a support nightmare, and the transaction boundary is what prevents it.

Partitioning inventory when one event is everything

How do you shard when a single event can be 90 % of the day's load?

Normal sharding advice (spread load evenly by hashing the key) fails here. The load is not evenly distributed and never will be: one on-sale at a time dominates, and it is known in advance.

Because the hot key is predictable and scheduled, you can do something better than balance: isolate.

  • Partition by event, with hot events isolated onto their own capacity chosen
  • Hash-partition by seat id rejected
  • Single database for everything situational: early on, or for a venue-scale product, and it is the right place to start
  • Partition by venue or region situational: residency rules force it, with per-event isolation layered on top

The answer: 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.

A buyer wants seats at two events in one basket. Now what?

Two independent holds, one per partition, coordinated by the order as a small saga: if the second hold fails, release the first. There is no atomic cross-event hold, and pretending otherwise would require distributed transactions on the hottest path in the system. The basket is an order-level concept, not an inventory-level one.

You cannot predict which event goes viral. What then?

Detect and shed. Per-event rate limiting means an unexpected surge is throttled into the waiting room rather than passed to the partition, and an event can be migrated to isolated capacity while live: briefly pausing holds for that event, which the client retries. Prediction is the optimisation; isolation and back-pressure are the guarantee.

How big does one partition need to be?

Sized by sustained writes per second for the largest sale: about a thousand write attempts per second, each a single-row conditional update. That is unremarkable for one well-provisioned node, which is the useful conclusion: the hard part of this system is admission control and correctness, not database throughput.

Do you need multi-region for inventory?

Not active-active: a seat cannot be authoritatively held in two regions without consensus on every hold, and the latency would exceed the budget. Run inventory single-region per event, close to where the demand is, with a warm standby and asynchronous replication. Reads and the waiting room are global; the invariant is not.

Related