System Design Prep
Interviewer kit

Design Ticketmaster

Run this for someone else. You hold the answers; they do not. Read the prompt, keep the clock, and use the probes below when an answer is thin. Do not show them this page.

The candidate should have practice mode or a blank page — not this.

Open with this

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. Take a couple of minutes on requirements, then we will do some numbers, then the design. I will interrupt to keep us moving.

The clock

  • 4 min — functional requirements and scope
  • 4 min — non-functional requirements, with numbers
  • 5 min — back-of-envelope estimates
  • 16 min — high-level design and one or two flows
  • 16 min — deep dives and the close

Move them on out loud when a section overruns. The commonest failure is spending twenty minutes on requirements and never reaching a deep dive, and preventing that is your job as much as theirs.

Requirements — 8 min

Listen for: a scoped set of capabilities, an explicit out-of-scope list, and numeric targets rather than adjectives. Prompt with “what are you not building?” if they never scope, and “what number would make that requirement real?” if they say “fast” or “highly available”.

Functional (7)
  • 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 (7)
  • 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.

Estimates — 5 min

Ask for two or three numbers, not all of them. What matters is whether they state assumptions, round sensibly, and say what the number implies. Push once with “where did that come from?”

The numbers (7)
  • 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.

High-level design — 16 min

Let them draw. Interrupt only to ask what backs a component or what a box actually does. Then pick one flow below and ask them to walk it end to end.

Components (13)
  • 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.
Flows to ask them to walk (5)
  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.
    2. Buyer selects two seats; the client asks to hold them.
    3. The reservation service claims both seats with a conditional update.
    4. The hold is indexed by expiry and published; the client starts a visible countdown.
    5. Buyer submits payment; the order service authorises against the provider.
    6. Authorisation succeeds; the order confirms the hold, turning it into a sale.
    7. The ticket issuer mints tickets from the sale event and delivers them.
  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.
    2. Forty hold requests arrive within the same second.
    3. The first conditional update wins; the other thirty-nine match zero rows.
    4. Losers get an immediate, specific rejection with the seats that are still free nearby.
    5. The sale is published and the availability bitmap updates within a second or two for everyone else.
  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.
    2. Admission is metered against how fast the purchase path is actually completing.
    3. Admitted users are handed a short-lived purchase token and reach the API.
    4. The queue position is shown and moves honestly.
    5. Inventory is exhausted; the queue is drained with a clear message rather than left spinning.
  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.
    2. The sweeper queries the expiry index for holds past their deadline.
    3. Each expired hold is released with a conditional update.
    4. Meanwhile a payment authorised at the provider after we gave up waiting.
    5. The order tries to confirm, finds the seats gone, and refunds immediately.
    6. The release is published and the seats reappear in the availability bitmap.
  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.
    2. The tier count is split into buckets and the request takes from one.
    3. A buyer whose bucket is empty falls through to another bucket rather than failing.
    4. The hold, the payment and the confirmation are the same machinery as reserved seating.
    5. Released GA holds return to the bucket they came from.

Deep dives — 16 min

Pick two. Ask the headline question, let them answer, then use the follow-ups. The follow-ups are where the level gets decided, so leave time for at least three of them.

How a seat is held

Ask: Database row lock, a distributed lock, a status column with an expiry, or an in-memory reservation service?

Good answers name: Status column with hold id and expiry, updated conditionally, SELECT … FOR UPDATE around read-modify-write, Distributed lock service (Redis / etcd) in front of the database, In-memory reservation service, single owner per event.

Our pick: 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.

  1. 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.
  2. 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.
  3. 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.
  4. 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

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

Good answers name: Edge waiting room with signed tokens and closed-loop admission, Pure rate limiting, no queue, Lottery: register in advance, randomly select buyers, Scale the purchase path to the arrival rate.

Our pick: 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.

  1. 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.
  2. 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.
  3. 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.
  4. 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

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

Good answers name: Bitmap snapshot in cache, served via CDN, plus deltas, Query the inventory database per page view, WebSocket push of every seat change to every viewer, Poll a versioned availability endpoint.

Our pick: 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.

  1. 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.
  2. 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.
  3. 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.
  4. 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

Ask: How do you avoid charging someone for a seat they do not get, or giving a seat away for free?

Good answers name: Hold, then authorise, then confirm — with idempotency and reconciliation, Charge first, then allocate a seat, Two-phase commit across payment and inventory, Saga with explicit compensations.

Our pick: 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.

  1. 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.
  2. 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.
  3. 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.
  4. 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

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

Good answers name: Partition by event, with hot events isolated onto their own capacity, Hash-partition by seat id, Single database for everything, Partition by venue or region.

Our pick: 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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.

Close — 5 min

Ask what breaks first at ten times the load, and what they would build next. Then give them your read: one thing that was strong, one thing that was missing, one thing to practise. Be specific; “good job” helps nobody.