← System design

Marketplace & Booking

Design Ticketmaster / StubHub

Most days a ticketing site is a calm catalogue. Then a stadium tour goes on sale and 2.5 million people show up for 60,000 seats in the same minute. The design has two jobs: hold back the crowd so only a few thousand people are shopping at once, and make every seat claim a single atomic step that can never hand the same seat to two buyers.
Core: atomic seat holds + virtual waiting room2.5M fans for 60K seatsZero double salesSell-out in about ten minutes

01

The problem

01

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

Correctness

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.

Throughput and fairness

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

02

What the platform must do

Functional
  • 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.
Non-Functional
  • 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

03

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.

QuantityWorkingResult
Seats on saleassumption60,000
Orders needed to sell out60,000 / 3.2 tickets per orderabout 19,000
People in the waiting roomassumption2.5M
Time an admitted shopper spendspick seats, hold, payabout 4 minutes
Admission rate chosenkeep about 10,000 shoppers active at onceabout 2,500 per minute
Time to sell out19,000 orders / about 2,000 successful per minuteabout 10 minutes
Queue status checks2.5M waiting x one poll every 20 sabout 125,000 / s
Seat map availability refreshes10,000 shoppers x one refresh every 5 sabout 2,000 / s
Hold attempts10,000 shoppers, several tries each over 4 minutesabout 300 / s, bursts to 1,500
Hold contentionmany shoppers aiming at the same front sectionshot rows in a few sections
Normal-day salesacross all eventsabout 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.

Design consequence

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

04

API design

POST/v1/events/{event_id}/queueJoin the waiting room
// Called when the user opens an on-sale page. Answered at the edge. // 200 OK { "queue_token": "qt_eyJldiI6ImV2Xzk5...", // signed: event, user, join time, random tiebreak "position_estimate": 418220, "estimated_wait_s": 1620, "poll_after_s": 20 }
GET/v1/events/{event_id}/queue/statusCheck position
// Header: X-Queue-Token { "state": "waiting", // or "admitted" "position_estimate": 391004, "admission_token": null // set once admitted, valid for 10 minutes of shopping }
GET/v1/events/{event_id}/availability?sections=112,113,114Live seat availability
// Served from a cache refreshed every few seconds. Static map comes from the CDN. { "as_of": "2026-10-01T10:00:41Z", "sections": { "112": { "available_bitmap": "base64:AP/8...", "price_levels": ["P1", "P2"] } } }
POST/v1/events/{event_id}/holdsHold seats while paying
// Header: X-Admission-Token // Idempotency-Key: h-73b1e0 { "seats": ["112-K-14", "112-K-15"], // or "best_available": { "price_level": "P2", "count": 2 } "buyer_id": "u_550012" } // 201 Created { "hold_id": "hold_8Q2W", "seats": ["112-K-14", "112-K-15"], "expires_at": "2026-10-01T10:08:41Z", "total": { "amount": 31800, "currency": "USD" } // cents, fees included } // 409 with the seats that were taken, so the client can suggest neighbours
POST/v1/holds/{hold_id}/purchasePay and confirm
{ "payment_method": "pm_card_tok_19c", "idempotency_key": "buy-hold_8Q2W" // derived from the hold, one purchase per hold } // 200 { "order_id": "ord_6620", "tickets": ["tkt_a1", "tkt_a2"] } // 410 if the hold expired before payment was authorised

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

05

Architecture overview

Waiting room in front, inventory behindDiagram
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" .-> ADM

Traffic 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

ComponentOwnsScales with
Edge and CDNStatic maps, event pages, token checksTotal visitors
Waiting roomQueue tokens, positions, admissionPeople waiting
Inventory serviceSeat states, holds, expiriesActive shoppers per event
Payment serviceAuthorisation and capture, idempotencyPurchases
Orders and ticketsOrders, ticket barcodes, transfersTickets 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

06

Deep dive: never selling a seat twice

The core question

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

-- Hold two seats for buyer u_550012 for 8 minutes. -- One statement: either every seat matches the condition, or the hold fails. UPDATE seats SET state = 'held', hold_id = 'hold_8Q2W', held_until = now() + interval '8 minutes' WHERE event_id = 'ev_99' AND seat_id IN ('112-K-14', '112-K-15') AND (state = 'available' OR (state = 'held' AND held_until < now())) RETURNING seat_id; -- If fewer rows come back than requested, roll back and report which seats were taken.

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

Purchase flow with a holdDiagram
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.

Layers of protection

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

07

Decisions and what they cost

Chosen
Relational database as the only seat truth

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 safe
Rejected
Redis locks in front of the database

Faster 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 mode
Chosen
Waiting room only for high-demand events

Most 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 days
Alternative
Always queue everyone

Uniform and predictable, but adds a step and a wait for the vast majority of events where demand is lower than supply.

Annoys most buyers
Chosen
Hold window of about eight minutes

Long enough to enter card details and pass 3-D Secure, short enough that abandoned seats return quickly during a rush.

Balanced
Alternative
Longer holds for normal events

Fifteen 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 event
Chosen
Random order for early arrivals, then first come first served

Removes the advantage of refreshing faster at the exact start time, which mostly helps bots. Later arrivals are queued in order.

Fairer, explainable
Alternative
Pure arrival order

Simple to explain, but rewards whoever has the fastest scripts and network. Pre-registration lotteries are a stronger option for the biggest tours.

Bot friendly

08

Failure modes

08

Failure modes

🧨
The inventory primary fails mid on-sale

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.
💳
Payment succeeds but confirmation fails

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.
🤖
Bots flood the waiting room

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.
🌀
The waiting room itself is overwhelmed

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 expiry sweeper stalls

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.
📉
Everyone wants the same section

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

⚠️
Check then write in application code

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.
⚠️
Relying on a background job to free seats

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.
⚠️
Letting everyone hit inventory at once

Millions of doomed hold attempts overwhelm the one database that matters.

Put a waiting room in front and admit a controlled number of shoppers.
⚠️
Charging before the seat is secured

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.
⚠️
Querying the seat database for every map view

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

09

How to handle the interview

1

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.

2

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.

3

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.

4

Design the waiting room deliberately. Signed tokens, random order for early arrivals, an admission controller with feedback, and status served from the edge.

5

Get payment ordering right. Authorise, convert, capture, with recovery for the crash in between. This is where many answers quietly charge people for nothing.

6

Handle general admission separately. Bucketed counters instead of seat rows, and say why one counter row would become a bottleneck.

7

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

11

Growing the design in stages

Rule of thumb

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.

Stage 1: a local box office
One app and one database

Seats in a table, holds via conditional updates, payment through a processor. Handles a theatre or a club with no extra parts.

Stage 2: regional promoter
CDN for maps, cache for availability

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.

Stage 3: arena tours
Waiting room and admission control

High-demand on-sales get a virtual waiting room with signed tokens and an admission controller that watches inventory latency.

Stage 4: national platform
Sharded inventory and bot defence

Events are spread across database shards, each event on one shard. Verified fan presales, device fingerprinting and per-buyer limits deal with scalpers.

Stage 5: stadium tours worldwide
Edge-served queues and multi-region

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

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.