Marketplace & Booking
Design Ticketmaster / StubHub
01
The problem
The problem
A ticketing platform sells seats to concerts, games and shows. On an ordinary day that is a quiet catalogue business: a few hundred thousand tickets across tens of thousands of events, browsed at a relaxed pace. Then a stadium tour goes on sale, and more than two million people arrive in the same minute for 60,000 seats.
The rest of the design follows from that mismatch. Demand is forty times supply, everyone arrives at once, and every seat is unique. Two people must never walk away holding the same seat, but you also cannot make two million people fight over database rows directly. You have to control how many people are shopping at any moment, then make each seat claim cheap, atomic and temporary until payment succeeds.
Two pressures that pull against each other
A seat is sold once. A hold that expires must free the seat for someone else. A payment that succeeds must turn into a ticket even if a server dies a moment later.
The system must stay up when demand is enormous, keep bots from buying everything, and give real fans a process they can see is fair: a visible queue position and a predictable wait.
02
What the platform must do
What the platform must do
- Browse and search events, see venue maps with live seat availability, and pick specific seats or ask for the best available in a price range.
- Support reserved seating and general admission sections, which are sold by count rather than by seat.
- Place a temporary hold on chosen seats while the buyer pays, and release them automatically if the hold expires.
- Run a virtual waiting room for high-demand on-sales, with a visible position and estimated wait.
- Take payment, issue mobile tickets, and let buyers transfer or resell them within event rules.
- Enforce per-buyer ticket limits and presale access codes.
- Zero double sales, under any failure, at any load.
- Holding a seat responds in under 300 ms at p99 for admitted shoppers, even during the largest on-sale.
- The waiting room absorbs 2.5 million arrivals in the first ten minutes without falling over.
- Seat maps may show availability a few seconds old; the hold step must check the true state.
- Queue order must be explainable to the public, and bots must not get an advantage from raw speed.
03
Sizing the load
Sizing the load
Size for the worst day, not the average one. Assume a stadium on-sale with 60,000 seats, 2.5 million people in the queue, and buyers taking about 3.2 tickets per order.
| Quantity | Working | Result |
|---|---|---|
| Seats on sale | assumption | 60,000 |
| Orders needed to sell out | 60,000 / 3.2 tickets per order | about 19,000 |
| People in the waiting room | assumption | 2.5M |
| Time an admitted shopper spends | pick seats, hold, pay | about 4 minutes |
| Admission rate chosen | keep about 10,000 shoppers active at once | about 2,500 per minute |
| Time to sell out | 19,000 orders / about 2,000 successful per minute | about 10 minutes |
| Queue status checks | 2.5M waiting x one poll every 20 s | about 125,000 / s |
| Seat map availability refreshes | 10,000 shoppers x one refresh every 5 s | about 2,000 / s |
| Hold attempts | 10,000 shoppers, several tries each over 4 minutes | about 300 / s, bursts to 1,500 |
| Hold contention | many shoppers aiming at the same front sections | hot rows in a few sections |
| Normal-day sales | across all events | about 400,000 tickets |
The spike that drives everything
Look at the gap between 125,000 queue checks a second and about 300 hold attempts a second. The waiting room exists to turn the first number into the second. If you let all 2.5 million people reach the seat map directly, the inventory service would see hundreds of thousands of hold attempts a second for the same few thousand good seats, nearly all of which would fail.
The inventory database never needs to be big. It needs to handle a few hundred contended writes a second on one event without ever selling a seat twice. Everything at higher volume, the queue, seat maps and event pages, is served from caches and the edge.
04
API design
API design
The seat-picking endpoints require an admission token. Without one, the request is rejected at the edge before it reaches any service, which is what keeps the waiting room meaningful.
05
Architecture overview
Architecture overview
flowchart LR
U["Fans"] --> CDN["CDN and edge"]
CDN --> WR["Waiting room service"]
WR --> QS[("Queue state")]
CDN --> API["Booking API"]
API --> INV["Inventory service"]
INV --> DB[("Seat inventory, Postgres per event shard")]
INV --> AV[("Availability cache")]
API --> PAY["Payment service"]
PAY --> PSP["Card processor"]
API --> ORD[("Orders and tickets")]
ORD -. "events" .-> NT["Ticket delivery and email"]
ADM["Admission controller"] --> WR
INV -. "load signal" .-> ADMTraffic meets the CDN and edge first. Event pages, venue maps and static assets are cached there, and the edge checks tokens: requests to the booking API without a valid admission token for that event never reach the origin.
For a high-demand event, the waiting room issues each arrival a signed queue token and stores a compact record of their place. An admission controller decides how many people to let through per minute, based on how many shoppers are currently active and on load signals from the inventory service. Admitted users get a short-lived admission token and go to the seat map.
The inventory service owns the truth about every seat. Each event's seats live on one shard of a relational database, so all writes for an event go to one primary and can use ordinary transactions. Availability for seat maps is published to a cache every few seconds; holds always go to the database. When a hold turns into a purchase, the booking API calls payment, and on success converts the hold to sold and writes tickets in one transaction.
Responsibilities
| Component | Owns | Scales with |
|---|---|---|
| Edge and CDN | Static maps, event pages, token checks | Total visitors |
| Waiting room | Queue tokens, positions, admission | People waiting |
| Inventory service | Seat states, holds, expiries | Active shoppers per event |
| Payment service | Authorisation and capture, idempotency | Purchases |
| Orders and tickets | Orders, ticket barcodes, transfers | Tickets sold |
Seat maps without hammering inventory
A stadium map is a large, static drawing that changes only when the venue is reconfigured, so it is served from the CDN as a vector file. What changes is which seats are free. The inventory service publishes a bitmap per section, one bit per seat, into the availability cache every two to five seconds. A 60,000-seat venue fits in under 8 KB, so the client can fetch the whole event's availability in one cheap request and colour the map locally. The map may be a few seconds stale; the hold call is what checks the real state.
06
Deep dive: never selling a seat twice
Deep dive: never selling a seat twice
Ten thousand admitted shoppers are clicking on the same front rows. How do you guarantee each seat goes to exactly one of them, give the losers a fast answer, and release seats from abandoned holds without a cleanup job you have to trust?
Why the obvious approach breaks
The naive version reads the seat, checks it is available in application code, then writes "held". Two requests that read at the same moment both see "available" and both write. Wrapping it in a transaction at the default isolation level does not help either, because both reads succeed before either write. You need the check and the claim to be one atomic operation on the database.
A conditional update is the claim
Each seat row is locked only for the instant the update touches it. If two shoppers race for 112-K-14, one update wins and the other sees the row no longer matches and returns zero rows for it. The loser's transaction rolls back its other seats too, so a buyer never ends up with half of a pair. The service then answers 409 with the seats that went, in milliseconds, and the client suggests nearby alternatives.
Notice the second half of the condition: a hold whose held_until has passed counts as available. That is lazy expiry. No background job has to release seats on time for correctness. A sweeper still runs every few seconds to flip expired holds back to available so the seat map shows them, but if the sweeper stalls, the seats are still claimable.
From hold to sale
flowchart TD
H["Hold placed, expires in 8 min"] --> P["Authorise card with idempotency key"]
P --> OK{"Authorised?"}
OK -->|no| E["Show error, hold stays until expiry"]
OK -->|yes| C["Transaction: seats held by this hold_id and not expired become sold"]
C --> R{"All seats converted?"}
R -->|yes| T["Write order and tickets, capture payment"]
R -->|no| V["Void the authorisation, apologise, offer alternatives"]Payment is authorised before the seats change to sold, and captured only after they have. The conversion is another conditional update: state = 'sold' where hold_id matches and the hold has not expired. If the buyer took too long and someone else claimed the seats after expiry, the update finds nothing, and the authorisation is voided so the buyer is never charged for seats they do not get.
To make the race rare rather than routine, the hold window shown to the buyer is slightly shorter than the one stored. The client counts down from seven and a half minutes while the database allows eight, which absorbs payment latency at the edge of the window.
Best available
When a buyer asks for "two seats together in price level P2", the service must pick seats without making every request fight over the same best ones. Order candidate seat blocks by quality, and claim with SELECT ... FOR UPDATE SKIP LOCKED over a short candidate list, then run the same conditional update. Skip-locked means a request that finds the top block being claimed by someone else moves to the next one instead of waiting, which spreads concurrent requests across the section.
General admission
A standing area with 8,000 places has no seat rows to claim, just a count. A single counter row for the section would serialise every hold for it. Split the section into allocation buckets, say 32 rows of 250 places each, and let each hold pick a random bucket with remaining capacity and decrement it with a conditional update. Contention drops thirty-two fold, and the total can never go below zero because each bucket checks its own count.
Why not a distributed lock in Redis?
A lock per seat in Redis is a popular answer. It is fast, but it adds a second place where "who holds this seat" is recorded, and the two can disagree after a failover or a client pause. If the database is already the source of truth and each event fits on one shard, the conditional update gives you the same atomicity with one system instead of two. Use Redis for what it is good at here: the availability cache and the waiting room counters.
The waiting room
People who arrive before the on-sale opens are all equally early, so ordering them by arrival rewards bots that refresh fastest. Instead, give everyone who joins before the start a random position within that early group, then queue later arrivals in order behind them. Each queue token records the event, the user, the join time and the random value, signed so the client cannot change it. Position estimates are computed from counters of tokens issued and admitted, which means a status check is a signature check plus two counter reads, cheap enough at 125,000 a second.
The admission controller works like a rate limiter with feedback. It targets a number of active shoppers, about 10,000 here, and admits more as shoppers finish or their admission tokens expire. If hold latency rises, it slows admission. When inventory runs out, it tells everyone remaining that the event is sold out, rather than letting them wait for nothing.
The edge rejects requests without admission tokens. The waiting room limits active shoppers. Per-buyer limits stop hoarding. The conditional update guarantees no seat is held twice, and a unique constraint on (event_id, seat_id) in the tickets table is the final backstop: even a bug elsewhere cannot issue two tickets for one seat.
07
Decisions and what they cost
Decisions and what they cost
Atomic conditional updates, transactions across seats and a unique constraint as a backstop. One event fits on one shard, so there is no cross-shard coordination.
Simple and safeFaster claims, but two sources of truth that can drift after failovers or long pauses. The extra speed is not needed once the queue limits shoppers.
Extra failure modeMost events sell gradually and need no queue. The queue switches on per event, by schedule or when traffic crosses a threshold.
No friction on normal daysUniform and predictable, but adds a step and a wait for the vast majority of events where demand is lower than supply.
Annoys most buyersLong enough to enter card details and pass 3-D Secure, short enough that abandoned seats return quickly during a rush.
BalancedFifteen minutes is kinder when demand is low. During an on-sale it locks up seats in carts that will be abandoned. Make it a per-event setting.
Tune per eventRemoves the advantage of refreshing faster at the exact start time, which mostly helps bots. Later arrivals are queued in order.
Fairer, explainableSimple to explain, but rewards whoever has the fastest scripts and network. Pre-registration lotteries are a stronger option for the biggest tours.
Bot friendly08
Failure modes
Failure modes
The database primary for the shard holding the event crashes with thousands of holds in flight.
A synchronous replica takes over within seconds, so no committed hold or sale is lost. The admission controller pauses new admissions until hold latency is back to normal, and buyers mid-checkout get their hold windows extended by the outage length.The card is authorised, then the booking API crashes before converting the hold to sold. The buyer sees a spinner, the hold expires, and someone else could take the seats.
Record the authorisation against the hold ID before attempting the conversion. A recovery worker scans for authorised holds without orders and either completes the conversion, if the hold is still valid, or voids the authorisation and notifies the buyer.Scalper networks open hundreds of thousands of sessions to win more early positions and then resell tickets.
Bind queue tokens to verified accounts, limit tokens per account, payment method and device fingerprint, use presale codes for verified fans, and enforce per-buyer ticket limits at purchase.Arrivals hit 4 million instead of the planned 2.5 million, and queue status checks exceed what the queue service can answer.
Serve status from the edge using the signed token and admission counters published every second. Clients back off their polling interval as their position gets larger, and the queue page itself is a static file on the CDN.The job that marks expired holds as available stops running. Seat maps show sections as unavailable that are actually free.
Correctness is unaffected because claims treat expired holds as available. Alert on sweeper lag and on a growing count of expired holds, and have the availability publisher treat expired holds as free when building bitmaps.Front-of-stage seats get thousands of hold attempts a second and most fail, so buyers retry endlessly and the database spends its time on doomed updates.
Return the list of taken seats in the 409 so the client can suggest neighbours, favour best-available requests that spread across blocks, and refresh section bitmaps faster for hot sections.09
Mistakes to avoid
Mistakes to avoid
Two requests read "available" at the same time and both sell the seat.
Make the check and the claim a single conditional update in the database.If the job stalls, seats stay locked and the event appears sold out.
Store an expiry time with each hold and treat expired holds as available in the claim itself.Millions of doomed hold attempts overwhelm the one database that matters.
Put a waiting room in front and admit a controlled number of shoppers.Buyers are charged for seats they do not get when a hold expires during payment.
Authorise first, convert the hold, then capture, and void the authorisation if conversion fails.Seat maps are viewed far more often than seats are claimed.
Publish availability bitmaps to a cache and serve the static map from the CDN.10
How to handle the interview
How to handle the interview
Lead with the spike. Say early that the hard case is one on-sale with demand far above supply, and size the queue, the admission rate and the hold rate separately.
Show the atomic claim in SQL. A conditional update with an expiry check is concrete and easy to reason about. Interviewers like seeing exactly which statement prevents a double sale.
Explain lazy expiry. Holds that expire by timestamp, not by a job, remove a whole class of bugs. Mention the sweeper only as a display concern.
Design the waiting room deliberately. Signed tokens, random order for early arrivals, an admission controller with feedback, and status served from the edge.
Get payment ordering right. Authorise, convert, capture, with recovery for the crash in between. This is where many answers quietly charge people for nothing.
Handle general admission separately. Bucketed counters instead of seat rows, and say why one counter row would become a bottleneck.
Have an opinion on Redis locks. Explain when they help and why one source of truth is safer when the queue already limits load.
11
Growing the design in stages
Growing the design in stages
Correctness is required from day one; scale is not. Start with the conditional update and the expiry timestamp, and add the queue when your biggest event needs it.
Seats in a table, holds via conditional updates, payment through a processor. Handles a theatre or a club with no extra parts.
Static venue maps move to the CDN and availability bitmaps to a cache, so browsing stops touching the seat database. A sweeper tidies expired holds for display.
High-demand on-sales get a virtual waiting room with signed tokens and an admission controller that watches inventory latency.
Events are spread across database shards, each event on one shard. Verified fan presales, device fingerprinting and per-buyer limits deal with scalpers.
Queue status is answered at the edge from signed tokens and published counters, and events are served from the region nearest the venue. Resale and transfers become their own system with their own fraud risks.
Watch and read
Further watching and reading
Try next
Free to read · better with landed
Whiteboard this with landed
landed runs it as a live system design round on the whiteboard and grades your trade-offs.
