System design interview
Design an event ticketing system
Requirements, estimates and a step-by-step design for an event ticketing system: seat holds with expiry, no overselling, a waiting room and a cached seat map.
Press Play to follow a request through the design, or step through it.
- The buyer arrives at opening time and joins the waiting room. They see their place in line.
- When admitted, the buyer gets a signed, time-limited pass and can ask for the seat map.
- The seat map service reads the cached snapshot, not the database.
- The cache returns every seat's status, up to 2 seconds old.
- The service returns the map to the buyer.
- The buyer sees the map and picks seats. A seat may have just gone, which the next step settles.
- Buyer
- Waiting room: admits in batches
- Seat map service: read only
- Hold service: hold, pay, confirm
- Seat map cache: snapshot, 1 to 2 s old
- Seats DB: seat state, expiry
- Payment provider
- Hold expiry job: releases lapsed holds
On this page
In 2 minutes
Two million people for 50,000 seats means most will lose, so I would protect the system first and decide winners second. A static waiting room admits buyers in batches, for example 20,000 at a time, and hands each a signed pass. That turns 400,000 seat-map reads a second into about 4,000, served from a cache that is refreshed from the database every second or two. When a buyer picks seats, the hold service runs one conditional update: set the seats held for 8 minutes, only where they are available or their hold has lapsed, and check that every requested seat changed. The database serialises writes to a row, so exactly one buyer wins each seat, and nothing is checked in application code or a cache. Then the buyer pays, and a second conditional update marks the seats sold only if the hold is still theirs. If payment succeeds too late, we refund. An expiry job returns lapsed holds, and the database clock is used throughout. Orders carry an idempotency key, and holds per buyer are limited. The trade-off is that most people wait.
The question
Clarifying questions
- Are tickets for numbered seats, or general admission? Numbered seats mean contention on individual rows. General admission is a shared counter, which is a different hot spot.
- How large is the event, and how many people try to buy at the opening? A mismatch between seats and demand is what turns a sale into a flash crowd, so the ratio drives the protection needed.
- How long can a buyer hold seats while paying? Short holds free seats faster but frustrate slow payers. Long holds let a few people lock up the whole venue.
- Do we need to fight bots and resellers? Limits per account, sign-in, and a fair queue all change how the front door of the system works.
- What happens if payment fails or the buyer walks away? The seat must return to the pool automatically and at the right moment, never earlier or later than intended.
Requirements
Functional
- Show the seat map with each seat's availability.
- Hold selected seats for a buyer for a limited time.
- Complete a purchase with payment, and issue the tickets.
- Release a hold automatically when it expires or payment fails.
Non-functional
- No seat is ever sold to two people: sold seats match orders exactly.
- Absorbs 2 million people arriving within 5 minutes without failing.
- Seat map view: p99 under 300 ms, no more than 2 seconds stale.
- A hold or purchase attempt answers within 1 second at p99.
Back-of-the-envelope estimates
Assumptions for this exercise:
- These numbers are assumptions for the exercise, not facts about any real sale.
- An event has 50,000 seats. 2 million people arrive within 5 minutes (300 seconds) of the sale opening.
- An order averages 2.5 seats. The checkout can complete about 100 orders a second.
- A shopper polls the seat map once every 5 seconds. A buyer can hold at most 4 seats, and 5,000 buyers are in checkout at any moment.
The arithmetic:
- Arrivals per second: 2M ÷ 300 s → ≈ 6,700/s at the start
- Seat map polls with no waiting room: 2M shoppers × 1 poll per 5 s → 400,000/s, far too many for the database
- Seat map polls with a waiting room: 20,000 admitted shoppers × 1 poll per 5 s → 4,000/s, easily served from a cache
- Time to sell out: 50,000 seats ÷ 2.5 = 20,000 orders ÷ 100 orders a second → 200 s, about 3 minutes
- Most seats held at once: 5,000 buyers in checkout × 4 seats → 20,000 seats, 40% of the venue
High-level design
Everyone first meets the Waiting room. It lets buyers in at a rate the backend can handle, and gives each admitted buyer a signed pass. The waiting page is static and cheap to serve, so it can take the whole crowd.
Admitted buyers see the seat map from the Seat map service, which reads a Seat map cache. The cache holds a snapshot of every seat's status, refreshed every second or two from the database. It may be a moment out of date, which is fine for browsing.
When a buyer picks seats, the Hold service tries to hold them in the Seats DB with one conditional update. If every seat was free, they are held for a few minutes, the buyer pays through the Payment provider, and the seats are marked sold. A Hold expiry job returns lapsed holds to the pool.
How a request flows
Wait, then view the seats
- The buyer arrives at opening time and joins the waiting room. They see their place in line.
- When admitted, the buyer gets a signed, time-limited pass and can ask for the seat map.
- The seat map service reads the cached snapshot, not the database.
- The cache returns every seat's status, up to 2 seconds old.
- The service returns the map to the buyer.
- The buyer sees the map and picks seats. A seat may have just gone, which the next step settles.
Hold, pay and confirm
- The buyer chooses two seats and presses buy. The pass is checked.
- The waiting room passes the request to the Hold service.
- One conditional update marks both seats held by this buyer for 8 minutes, only if they are available.
- Two rows changed, so both seats are held. If only one had, the hold is rolled back and the buyer is told.
- The service charges the card while the hold is still valid.
- The provider confirms the payment.
- The seats are marked sold, only if this buyer's hold is still in place.
- The order is confirmed and the buyer receives their tickets.
A hold expires
- Every few seconds the expiry job asks the database for held seats whose time has passed.
- The database returns the lapsed holds.
- The job returns each seat to available, only if it is still held and still expired.
- A change event refreshes the cached seat map, so other buyers see the seats again.
Deep dive: seat holds with an expiry
A seat has three states: available, held and sold. A held seat records who holds it and when the hold ends. The flow is hold, then pay, then confirm.
To hold seats, run one update that changes all the requested seats together:
- Set each seat to held, with the buyer and an end time of now plus 8 minutes.
- Do it only where the seat is available, or is held but already expired.
- Compare the number of rows changed with the number of seats asked for. If they match, commit. If not, roll back, so a buyer never ends up with half of the seats they wanted.
Because the update also takes over expired holds, a lapsed seat can be sold at once, without waiting for any cleanup job. The expiry job still runs every few seconds, to put lapsed seats back to available, so the cached seat map and counts stay accurate.
Use the database's clock for all expiry times, not the clocks of the web servers, which differ.
Confirm with a second conditional update: mark the seats sold only where this buyer still holds them and the hold has not ended. If payment is slow and the hold has lapsed, someone else may have the seat. Check the remaining hold time before charging, and refuse to start a payment that cannot finish in time. If the payment still succeeds on a lost seat, refund automatically and tell the buyer.
Pick the hold length as a trade-off. Short holds return seats faster, but may expire while someone types their card details. Long holds let a few buyers lock up much of a venue. Limit each buyer to a few seats, and one active hold, to deter hoarding.
Deep dive: no overselling
The rule: the database row for the seat is the only authority. Caches and the seat map are only views. The decision about who gets a seat must be one atomic step against that row.
That step is a compare-and-set: change the seat to held if, and only if, it is currently available. The database serialises writes to one row, so of 1,000 simultaneous attempts exactly one finds the seat available, and the others see zero rows changed. No locks are held across calls, and nothing runs in the application between the check and the write.
There are several ways to express it.
- A conditional update, as above, where the condition is in the WHERE clause.
- A version column. Read the seat with its version number, then update it only where the version is unchanged. If another buyer got there first, the version moved and the update fails. Retry or report that the seat is gone.
- A row lock. Select the seat for update, check it, then write. It works, but holds a lock for longer.
Avoid doing the check in application code, or in a cache. Two servers can both read 'available' and both write 'held'.
Make purchases idempotent. The client sends an order key, and a unique constraint on it means a double-click or a retry returns the existing order and never creates another.
For general admission, with no seat numbers, a single counter row would be a hot spot. A conditional update still works: add to sold only where sold plus the quantity stays within capacity. If one row cannot take the write rate, split the counter into several sub-counters, each with its own share of the capacity, and try another when one is full.
Shard by event so that a sale touches one shard. 50,000 seat rows are tiny, and the database copes easily with the 100 or so orders a second after the waiting room has done its job.
Deep dive: a flash sale: the waiting room and the seat map
Without protection, 2 million shoppers polling the seat map every 5 seconds make 400,000 requests a second. The waiting room limits that. A virtual queue admits only as many buyers as the backend can serve, say 20,000 at a time, and the seat map load drops to 4,000 requests a second from cache.
How the waiting room works:
- Visitors get a place in line. For fairness, people who arrive before the sale opens can be put in a random order, not first-come, which rewards fast connections.
- The waiting page is static, so a CDN serves it. It checks its position now and then, with a random delay between checks to avoid a rush.
- As buyers finish or leave, the room admits the next batch and gives each a signed pass with an expiry. The seat map and hold endpoints reject requests without a valid pass.
- Add friction for bots: require sign-in, limit one place per account and payment method, and use a challenge for suspicious traffic.
The seat map is read by thousands and changes only when a seat does. Keep a compact snapshot of seat status, for example one value per seat grouped by section, in a cache. Refresh it every second or two from the database. A tiny staleness is acceptable, because the hold step is the real check. When a buyer picks a seat that just went, the hold fails with a clear message and the map marks the seat taken.
Reduce the fights over the best seats. Offer 'best available, 2 together in this section', and let the server choose among good seats at random. Buyers then do not all aim at the same few seats.
Protect the database as a last line. Limit hold attempts per pass, and queue holds if the rate is too high.
Trade-offs
- Where a hold lives: A conditional update in the database, with an expiry over A lock held only in a cache. A hold is as durable as the sale itself and survives cache failures. The cost is a database write for each hold.
- Seat map: A cached snapshot, 1 to 2 seconds old over Read every map from the database. It turns 400,000 reads a second into 4,000 served from memory. The cost is that a buyer can click a seat that has just gone.
- Taming the crowd: A waiting room with signed passes over Let everyone in and scale up. The backend sees a steady load it can handle, and the wait is fair. The cost is that most people wait, and they must be shown clear progress.
- Hold length: 8 minutes, with a limit of 4 seats over A very short or a very long hold. It is long enough to pay but short enough to recycle seats fast. The cost is the occasional buyer who runs out of time.
Common follow-up questions
How do you deal with bots and resellers?
Require a verified account, and limit each account and payment method to one place in the queue and a few seats. Challenge suspicious sessions, and watch for patterns such as many holds that never complete. No defence is perfect, so layer several of them and keep an eye on the data.
What if payment succeeds after the hold has expired?
Check the remaining hold time before charging, so this is rare. If it still happens, the confirm step fails, because the buyer no longer holds the seats. The system refunds automatically and tells the buyer, ideally offering similar seats.
How would you sell general admission tickets?
Use a counter of tickets sold per ticket type, updated with a conditional statement that never exceeds capacity. If one counter is too hot, split it into sub-counters with their own shares. Holds become reserved counts with an expiry, much like seat holds.
What happens when someone cancels or transfers a ticket?
A cancel moves the seat from sold back to available in a conditional update and starts a refund. A transfer changes the owner on the ticket and issues a new code, so the old one stops working. Both are logged, and both are idempotent.