Design Ticket Booking
Sell seats for a popular event without selling one twice: seat holds that expire, locking choices, a virtual waiting room for the spike, and payment.
On this page
A band announces one stadium show. Tickets go on sale Friday at 10:00. At 9:59 two million fans are sitting on the page, and there are 50,000 seats. Within the next few minutes, each seat must go to exactly one person, every person who pays must get a seat, and the site must not fall over while it happens.
Interviewers like this question because the normal day is easy and the hard parts are sharp: two people clicking the same seat in the same second, a huge spike at one moment, and a payment step that takes minutes while the seat stays reserved. The steps follow A Framework for Any Design. Try each "Your turn" box before you read on.
Clarify the problem
Design a ticket booking system for concerts and sports.
Are seats assigned, so a fan picks row C seat 12, or is it general admission where you just buy a count?
Assigned seats. Fans pick from a seat map.
What does a big on-sale look like? How many seats, and how many people show up at once?
The biggest shows have about 50,000 seats and two million people waiting at 10:00. Across the whole platform we sell about 5 million orders a month.
Then the steady load is small and the on-sale is the design problem. My priorities: never sell a seat twice, never take money without issuing a ticket, and stay up during the spike even if that means making people wait in line. A fan should get a few minutes to pay once they pick seats. I'll leave out resale, pricing and the event setup tools.
- Functional: browse events and a live seat map; pick seats and hold them for a few minutes; pay and receive tickets; release seats that are not paid for.
- Non-functional: no seat is ever sold twice (strong consistency on inventory); a paid order always ends with tickets or a refund; the site stays up when demand is 80 times the supply; seat holds last 10 minutes.
- Out of scope: resale, dynamic pricing, event creation, ticket scanning at the door.
Estimate the scale
Do the normal day, then the on-sale.
- Normal orders: 5 million a month ÷ 2.6 million seconds ≈ 2 orders a second. Browsing is maybe 100 times that: 200 reads a second. One database handles it.
- Demand at the on-sale: 50,000 seats at about 2 per order is 25,000 orders. Two million fans for 25,000 orders is 80 fans per order. Most of them will leave with nothing, and the system has to tell them so quickly.
- Without any control: if two million fans refresh the seat map every 5 seconds, that is 2,000,000 ÷ 5 = 400,000 requests a second, all aimed at one event's 50,000 rows.
- Storage: 100,000 events a year × 2,000 seats × 100 B = 20 GB of seat rows; 60 million orders × 1 KB = 60 GB. Size is not the problem; contention is.
You allow at most 10,000 fans in the booking flow at once, and a fan spends about 5 minutes picking seats and paying. How many new fans per second can you admit from the waiting room?
- At most 10,000 fans in the booking flow at once
- Each fan spends about 5 minutes (300 seconds) in the flow
Show the working
- A slot frees up every 300 seconds per fan
- 10,000 slots ÷ 300 s ≈ 33 fans a second, about 2,000 a minute
Estimate 33/s (anything from 10/s to 100/s is a good answer)
At 33 a second the 25,000 orders sell out in about 25,000 ÷ 33 ≈ 750 seconds, roughly 12 minutes, if everyone admitted buys.
Two million fans wait in line, and each browser checks its place every 10 seconds. How many requests per second is that?
- 2 million fans waiting
- One status check per fan every 10 seconds
Show the working
- 2,000,000 ÷ 10 s = 200,000 requests a second
Estimate 200 thousand/s (anything from 60 thousand/s to 600 thousand/s is a good answer)
That is too much for any database, so the waiting room must answer without touching one. The design below makes it a cached file.
Write the endpoints for joining the line, loading the seat map, holding seats and paying. What does each return when seats are already gone?
Compare with a model answer
Join returns a place in line; the seat map is a read; holding seats creates an order with an expiry; paying confirms it. The key error is "those seats were just taken", which must return fresh availability so the fan can pick again.
Define the API
POST /events/{id}/queue join the line
200 { "ticket": "signed...", "position": 48213 }
GET /events/{id}/queue/status cached for 1 s at the CDN
200 { "admitted_up_to": 40500, "sold_out": false }
POST /events/{id}/queue/pass exchange an admitted ticket
200 { "pass": "signed...", "expires_in": 900 }
403 Forbidden your number has not been reached yet
GET /events/{id}/seats?section=C needs a pass
200 { "seats": [ { "id": "C-12", "status": "available", "price": 9500 }, ... ] }
POST /events/{id}/holds needs a pass
body: { "seat_ids": ["C-12", "C-13"] }
201 { "order_id": "9001", "held_until": "10:31:04" }
409 Conflict one or more seats are taken; body lists them
POST /orders/{id}/pay Idempotency-Key: 9001
200 { "status": "paid", "tickets": [...] }
402 Payment Required card declined; the hold stays until it expires
410 Gone the hold expired before payment started
DELETE /orders/{id} release a hold earlyThe API decision worth arguing is the waiting-room status. Each fan's ticket carries their number, so the server publishes one number per event: "admitted up to 40,500". Every fan gets the same tiny response, so a CDN caches it for a second and absorbs nearly all 200,000 requests a second. The browser works out its position by subtraction.
Make pay safe to retry with an idempotency key; the order id is a natural one. A fan who double-clicks "Pay" on a slow connection must not be charged twice.
Sketch the records for seats and orders. How do you represent a hold so that it disappears on its own if the fan walks away?
Compare with a model answer
Put the hold on the seat row itself: a status, the order that holds it, and a held_until time. An expired hold counts as free in the query that claims seats, so nothing has to run for a hold to lapse.
Model the data
seats
event_id BIGINT
seat_id VARCHAR(16) -- "C-12"
section VARCHAR(8)
price_cents INT
status VARCHAR(10) -- available | held | sold
order_id BIGINT NULL -- who holds or owns it
held_until TIMESTAMP NULL
PRIMARY KEY (event_id, seat_id)
orders
order_id BIGINT PRIMARY KEY
user_id BIGINT
event_id BIGINT
status VARCHAR(10) -- held | paying | paid | expired | refunded
amount_cents INT
payment_id VARCHAR(64) NULL
created_at TIMESTAMPAccess patterns: read the seats of one event or section, claim a few seats of one event at once, and turn an order's seats into sold. The claim must be all-or-nothing across several rows, which points to a relational database with transactions, partitioned by event_id when one machine is not enough. A key-value store can do single-row conditional writes, but not an atomic claim of two seats.
Hold the seat in the row, not in a database lock. A database lock is meant to last milliseconds; a fan takes minutes to pay. The hold is a business-level lock written as data, with its own expiry.
Sketch the boxes: where does a fan wait, what checks that they were admitted, where does the seat map come from, and where do holds and sales get written?
Compare with a model answer
A waiting room in front of everything, mostly served from a CDN; a gateway that checks a signed pass without a database call; a booking service that reads the seat map from a cache and writes holds and sales to the inventory database; and an external payment processor.
High-level design
The design separates two crowds. The two million waiting fans only touch the waiting room. The 10,000 admitted fans reach the booking service, the only writer to inventory.
request and replyasynchronous message
Step through a fan getting seats, a second fan losing a race, and a hold that lapses.
- Ana's number
- 48,213
- fans in flow
- 10,000
1Ana joined at 10:00 with number 48,213 in a signed ticket. Every 10 seconds her browser polls the cached status: admitted up to 40,500, so she waits.
- Ana's number
- 48,213
- fans in flow
- 10,000
2Admitted fans finish or time out, so the waiting room raises the number to 48,300. Ana trades her ticket for a 15-minute pass.
- C-12
- available
- C-13
- available
3She opens section C. The gateway checks the pass signature locally, and the booking service returns the section from the seat map cache, about a second old.
- C-12
- held by 9001
- C-13
- held by 9001
- held until
- 10:31
4Ana picks C-12 and C-13. The booking service creates order 9001 and runs one conditional update: claim both seats only if each is available or its hold has expired. 2 rows change, so she has both.
- C-12
- held by 9001
- C-13
- held by 9001
- C-14
- available
5A second later Ben, whose cached map still shows C-13 free, tries C-13 and C-14. The update changes only C-14, so it rolls back and he gets 409 with fresh availability.
- C-12
- held by 9001
- C-13
- held by 9001
- order 9001
- paying
6Ana pays. The order moves to
payingand the hold is extended to cover the slowest payment, so it cannot lapse mid-charge. The processor is called with order 9001 as the idempotency key.- C-12
- sold
- C-13
- sold
- order 9001
- paid
7The charge succeeds. One transaction marks both seats sold and the order paid. Tickets go back to Ana and her slot in the flow frees up for the next fan.
- C-14
- held, expired
- C-15
- held, expired
8Ben holds C-14 and C-15 instead, then closes his laptop. Nothing has to run for the hold to lapse: ten minutes later the claim query already treats it as free.
- C-14
- available
- C-15
- available
9A sweeper runs every 30 seconds, sets lapsed seats back to available and marks the order expired, so the seat map shows them again.
Deep dives
Interviewers push on three things: stopping two buyers getting one seat, keeping the spike away from the database, and the gap between holding and paying.
Preventing double booking
Two requests for C-13 arrive within a millisecond. Each, on its own, would read "available" and write "held". One of them must fail.
| Option | Good at | Bad at | Pick it when |
|---|---|---|---|
| Pessimistic lock | SELECT … FOR UPDATE locks the rows, then you check and write. Easy to reason about. | Other buyers of those rows queue behind the lock. Holding it while the fan pays would freeze seats for minutes. | The check needs several reads before the write, and the transaction is short. |
| Optimistic version check | No locks while reading. Write with WHERE version = 7; if someone else wrote first, nothing changes and you retry. | Under heavy contention for the same rows, most attempts fail and retry, wasting work. | Conflicts are rare: a normal day, or an editing screen. |
| Conditional update Usual default | One statement claims the seats only if they are free. One round trip, and the row lock lasts only for that statement. | The rule must fit in a WHERE clause. The caller must check how many rows changed. | Claiming a known set of seats. The usual answer here. |
| Holds in an in-memory store | Very fast; "set if absent with a 10-minute expiry" gives holds that vanish on their own. | A second source of truth. If the store fails over and loses a recent write, two fans hold one seat, so the sale must still be checked in the database. | The database cannot take the hold rate even behind a waiting room. |
BEGIN;
UPDATE seats
SET status = 'held', order_id = 9001,
held_until = now() + interval '10 minutes'
WHERE event_id = 77 AND seat_id IN ('C-12', 'C-13')
AND (status = 'available'
OR (status = 'held' AND held_until < now()));
-- rows changed = 2: COMMIT. fewer than 2: ROLLBACK, report 409.
COMMIT;Why this is safe: when two transactions update the same row, the database lets one go first and makes the other wait for its row lock. At the default read-committed level in databases such as PostgreSQL, the waiting update then re-checks its WHERE clause against the new row, sees held, and changes nothing. At repeatable read or serializable, the waiting update fails with a serialization error instead. Either way the second buyer does not get the seat; treat the error like a 409. Databases differ in these details (see transactions and isolation), so name the level and the behaviour you rely on. Two buyers grabbing overlapping seats in opposite order can deadlock; the database aborts one, which reports the seats as taken.
I would pick the conditional update: one round trip, and no lock held while anyone thinks. General admission changes the details, not the choice: see the follow-up questions.
Surviving the on-sale spike
Adding servers does not solve this. The 50,000 seats live on one partition, and more booking servers only send it more conflicting writes. Instead, control how many fans reach the booking flow: a virtual waiting room.
- Join: each fan gets a number from a counter, inside a signed ticket. Numbers handed out before 10:00 are shuffled at 10:00, so sending a request at 9:59:59.999 gains nothing; after that it is first come, first served.
- Wait: the only thing polled is "admitted up to N", one cached number per event, so 200,000 polls a second cost almost nothing.
- Admit: a controller keeps at most 10,000 fans in the flow and raises N whenever an order completes or a pass times out. The database sees about 33 new fans a second, whatever the crowd size.
- Enter: an admitted fan gets a signed pass, valid for 15 minutes and tied to their account. The gateway checks the signature without any shared state.
- Stop: when no seat is available or held, tell everyone waiting it is sold out. A line of two million at 33 a second would take 2,000,000 ÷ 33 ≈ 60,000 seconds, about 17 hours, to drain.
Admitted fans read the seat map from a cache rebuilt about once a second. That is fine because the map is advice and the conditional update is the truth: a stale map costs a fan one 409, never a double sale.
Paying and releasing holds
The gap between "held" and "sold" is where the bugs live.
- The hold must outlive the payment. If a hold lapses at 10:31:04 and the charge succeeds at 10:31:05, a second fan may already hold that seat. So when the fan presses Pay, move the order to
payingand extendheld_untilpast the longest the processor can take. Holds inpayingare never swept. - Confirm with a check. The sale is
UPDATE seats SET status = 'sold' WHERE order_id = 9001 AND status = 'held', and it must change both rows. If it ever does not, refund the order automatically. - An unknown payment result is not a failure. If the processor call times out, the charge may have gone through. Keep the order in
paying, ask the processor for the result using the idempotency key, and only then sell or release. Design a Payment System covers this in depth.
Bottlenecks and failures
At 10x, twenty million fans for one show, the waiting room absorbs it: 2 million polls a second for one cached number is a CDN problem, not a database problem. The booking side does not change, because the admission cap sets its load. For many on-sales at once, partition inventory by event_id so each big show has its own partition.
| When this dies | What happens | What you do |
|---|---|---|
| A booking server | Requests in flight fail; holds already committed are safe in the database. | The fan retries; the gateway sends them to another server. |
| The seat map cache | If each admitted fan refreshes every 5 seconds, 10,000 ÷ 5 = 2,000 reads a second fall through to the database. | Serve a stale copy from each booking server's memory; holds are unaffected. |
| The inventory primary | Holds and sales stop until a replica takes over. | Replicate synchronously, so an acknowledged hold is on two machines. Pause admissions during the failover. |
| The payment processor | Fans cannot pay; holds start lapsing. | Pause admissions and extend unexpired holds, so fans are not punished for an outage. |
| The sweeper | Lapsed holds still look held on the map. | No seat is lost: the claim query treats them as free. Restart it. |
| The waiting room origin | The CDN serves the last admitted number; nobody new gets in. | Run it in several zones. Admitted fans keep booking. |
What a strong answer sounds like
| Level | What the answer does |
|---|---|
| Junior | Draws a booking service and a database, and with prompting adds a transaction around "check the seat, then book it". |
| Mid | Adds holds with an expiry, uses a conditional update or a lock and explains why, and puts a cache in front of the seat map. |
| Senior | Sees that the spike cannot be scaled away because it all lands on one partition, introduces a waiting room with a cheap status endpoint, protects the hold-to-payment gap, and says what each choice costs. |
Two million people arrive at 10:00. What do you do?
Autoscale the booking servers and put a cache in front of the database.
Two million people arrive at 10:00. What do you do?
All of that demand lands on one event's rows, so I can't scale it out. I'd put a waiting room in front and admit about 33 fans a second, which keeps 10,000 in the flow. The waiting room only publishes "admitted up to N", so 200,000 polls a second hit a CDN. The cost is that fans at the back wait, and I'd tell them it's sold out as soon as it is.
Why not just return 429 when you're overloaded?
It protects the database, but it rewards whoever retries fastest, which is usually bots. A line is fairer and gives a predictable load.
Follow-up questions
How would you handle general admission?
There are no seat rows, just a count per section, and every buyer decrements it with the same conditional update: SET remaining = remaining - 2 WHERE remaining >= 2. That one row is the hot spot, because every claim waits for the row lock. Split the count across, say, 10 rows and let each request try a random row that still has tickets, so ten claims can commit at once.
How do you stop bots from buying everything?
One place in line per verified account, a challenge when joining, and a cap on tickets per order and per account.
A group wants six seats together. What changes?
Offer "best available" instead of picking from the map: the booking service searches for six adjacent free seats and claims them with the same all-or-nothing update. If one was just taken, search again.
Could you push seat changes to the map instead of polling?
Yes: send each hold and sale to the admitted fans viewing that section over server-sent events. It cuts 409s but adds 10,000 open connections and a fan-out per write. Since the claim is the truth, a map one second stale is harmless, so start with the cache and add push only if 409s are frequent.
Why not hold the seats for 30 minutes to be kind?
Every minute of hold is a minute an abandoned seat is hidden from fans still in line. Ten minutes, extended only once payment starts, balances both.
After your attempt, tick what you really did. Be strict: "I thought about it" is not "I said it out loud".