Designing Ticketmaster is mostly about selling each seat exactly once while far more people than seats try to buy at the same moment. Hold each seat with one conditional database write that expires after a few minutes. Put a waiting room in front of checkout so the booking service only sees as many shoppers as it was sized for. Serve the seat map from a cache that can be a second or two stale, because the hold write decides who gets the seat.
Requirements
Before you draw anything, ask whether seats are assigned or general admission, and how many tickets one order can hold. Assigned seats make every seat its own row to fight over, and the order limit sets how many rows one request touches.
The core flow has four functional requirements:
- Show an event and its seat map with current availability.
- Let a buyer hold one or more seats for a few minutes.
- Turn a hold into a sale when payment succeeds.
- Release holds that expire or are abandoned.
Event search is an ordinary read-and-index problem. Name it, and cover it later if the interviewer wants it.
Most of the design comes from the non-functional requirements. A seat must never be sold twice, so seat state needs strong consistency. The seat map can be a few seconds stale. The system has to stay up when an on-sale opens and a crowd arrives in the same minute, and it should admit that crowd in a fair order that bots cannot easily game.
Estimates
Two published Ticketmaster figures make good anchors. In its November 19, 2022 post on the Eras Tour on-sale, Ticketmaster said invited Verified Fan buyers have historically bought an average of 3 tickets, and that it usually takes about an hour to sell through a stadium show. Everything else here is an assumption you would state out loud.
Assume a 50,000-seat stadium.
- 50,000 seats ÷ 3 tickets per order ≈ 16,700 orders.
- Spread over an hour, 16,700 ÷ 3,600 seconds ≈ 4.6 orders per second.
- If each order needs about 3 hold attempts because first-choice seats are taken, that is 4.6 × 3 ≈ 14 hold writes per second.
One relational database handles 14 writes a second easily. The hard part is that many of those writes aim at the same good seats in the same second.
Now assume 500,000 people are waiting when the sale opens.
- 500,000 ÷ 16,700 ≈ 30 people for each possible order, so at most 1 in 30 of them can buy.
- With no waiting room, if all 500,000 refresh the seat map every 5 seconds, that is 500,000 ÷ 5 = 100,000 requests per second.
- The venue layout does not change during a sale, so cache it on its own. Availability needs only 1 bit per seat, and 50,000 bits ÷ 8 = 6,250 bytes.
- Sending that bitmap 100,000 times a second is 100,000 × 6,250 bytes = 625 MB per second, about 5 Gbit/s.
Real traffic can be far larger. In the same post, Ticketmaster wrote that bot attacks and fans without codes drove 3.5 billion total system requests during the Eras Tour on-sale, 4 times its previous peak. So most of the design work goes into the read side and the crowd waiting for the sale. The booking write path carries little traffic, and its job is to be correct.
Data model
Keep the venue layout and the per-event seat state in separate tables. The layout belongs to the venue and is shared by every event there. Seat state is created per event, so this stadium gets 50,000 event_seat rows when the event is set up.
venue_seat (venue_id, seat_id, section, row, number, map_x, map_y)
event (event_id, venue_id, on_sale_at, ...)
event_seat (event_id, seat_id, price_tier, seat_rank,
status, -- 'available' | 'held' | 'sold'
hold_id, hold_expires_at,
PRIMARY KEY (event_id, seat_id))
hold (hold_id, event_id, user_id, seat_count, expires_at)
orders (order_id, hold_id UNIQUE, user_id, amount, payment_id, state)
Seat state lives on the seat row because the hold has to be one conditional write to that row. The unique hold_id on orders means one hold can become at most one order, which matters when a payment confirmation arrives twice. Partition event_seat by event_id if you need to scale out. That spreads different events across machines, but it does nothing for the hot seats inside one event.
Seat holds
The weak answer reads the seat, sees it is available, then marks it held in a second statement. Two buyers can both read "available" before either writes, and both think they have the seat.
Do the check and the write in one statement, inside a transaction that also inserts the hold row:
UPDATE event_seat
SET status = 'held',
hold_id = $3,
hold_expires_at = now() + interval '8 minutes'
WHERE event_id = $1
AND seat_id = ANY($2)
AND (status = 'available'
OR (status = 'held' AND hold_expires_at < now()));
Compare the number of rows updated with the number of seats requested. If it is short, roll back and tell the buyer which seats are gone. A group of four either gets all four seats or none.
Be ready to say which isolation level this needs. Read Committed, the PostgreSQL default, is enough for this statement, and the PostgreSQL documentation on transaction isolation explains why. When an UPDATE finds a row that another transaction is changing, it waits for that transaction to finish. If the other transaction commits, the WHERE clause is re-evaluated against the new version of the row. So the second buyer's update sees status = 'held' and matches zero rows, which is the result you want. Under Repeatable Read, the same race ends with the error "could not serialize access due to concurrent update", and the application has to retry the whole transaction.
Two multi-seat requests for overlapping seats can lock them in different orders and deadlock. The PostgreSQL locking documentation says the database detects the deadlock and aborts one of the transactions, and that the best defence is to take locks in a consistent order. Treat the aborted transaction as a lost race. If deadlocks show up often under load, lock the requested seats first with SELECT ... ORDER BY seat_id FOR UPDATE.
Expiry
The expiry check sits in the WHERE clause, so an expired hold is free to take the moment it expires. No background job has to run first. A sweeper can still reset old expired rows every few minutes, which keeps reports of held seats accurate. Its timing does not decide when a seat goes back on sale.
The hold length is a tradeoff you should name. A longer hold gives slow payers time to finish. It also keeps abandoned seats off sale for longer during the busiest minutes of the sale. The SQL above uses 8 minutes as an assumption. Ask how long checkout takes, and make the length a setting per event.
Best available
Buyers can also ask for "two seats in this price tier" instead of picking seats on the map. Let the server choose, and have it skip any seat another request is claiming at that instant:
WITH picked AS (
SELECT seat_id FROM event_seat
WHERE event_id = $1 AND price_tier = $2
AND (status = 'available'
OR (status = 'held' AND hold_expires_at < now()))
ORDER BY seat_rank
LIMIT $3
FOR UPDATE SKIP LOCKED
)
UPDATE event_seat s
SET status = 'held', hold_id = $4,
hold_expires_at = now() + interval '8 minutes'
FROM picked
WHERE s.event_id = $1 AND s.seat_id = picked.seat_id;
The PostgreSQL SELECT documentation says that with SKIP LOCKED, rows that cannot be locked immediately are skipped. It warns that this gives an inconsistent view of the data and suggests the option for queue-like tables where many consumers compete for rows. A best-available buyer wants any good seats in the tier, so that view is acceptable here, and two best-available requests in the same millisecond take different seats without waiting for each other. Check the row count here too, since the tier may have fewer seats left than the buyer asked for. Keeping a group's seats next to each other is a separate allocation problem. Mention it and leave it unless asked.
Other places to keep the hold
| Approach | How the hold expires | What goes wrong | When to use it |
|---|---|---|---|
| Conditional update on the seat row | Expiry time checked in the WHERE clause | Every hold write goes to the primary database | Start here. It fits until hold writes outgrow one primary |
| Redis key per seat, then a conditional write at payment | Redis removes the key when its TTL ends | Two systems to keep in step, and a failover can give the same seat to two buyers | When hold traffic across many events is too much for the primary |
| Row lock held from seat click until payment | Only when the transaction ends | A transaction stays open for minutes while the buyer types | Avoid it for the hold |
The Redis version uses SET hold:{event}:{seat} {hold_id} NX EX 480. With NX, the Redis SET command only sets the key if it does not already exist, so one buyer gets it, and EX 480 expires it after 8 minutes. The Redis distributed locks guide warns that Redis replication is asynchronous. If the primary crashes after granting a lock and before the replica has it, a second client can get the same lock. That is why the database still makes the final conditional write when the payment succeeds.
The PostgreSQL locking documentation calls it a bad idea to hold transactions open for long periods, such as while waiting for user input, and a row lock held until the buyer pays does exactly that. A buyer who closes the tab leaves that transaction open with its locks. The PostgreSQL setting that ends such sessions, idle_in_transaction_session_timeout, is disabled by default.
Payment
The hold gives the buyer time to pay. Payment then has to turn exactly one hold into exactly one order, even when messages arrive twice or late.
- The hold succeeds, and the client gets the
hold_idand the expiry time. - The server creates the payment with
hold_idas the idempotency key. Stripe's idempotency documentation says later requests with the same key return the same result, so a retry after a timeout cannot charge the buyer twice. - The payment provider reports success through a webhook. Stripe's webhook documentation says an endpoint might occasionally receive the same event more than once.
- One transaction confirms the sale. It inserts the order first. If an order for this
hold_idalready exists, the unique constraint stops it, and the handler treats the webhook as a duplicate and does nothing more. Otherwise it updates the seats tosoldwherehold_idmatches and the status is stillheld. - If fewer seats update than the hold covered, another buyer took a seat after this hold expired. Roll back, refund the payment, and tell the buyer. Derive the refund's idempotency key from the
hold_idtoo, so a repeated webhook cannot refund twice.
Step 4 does not check the expiry time. A buyer whose payment lands 30 seconds after the hold expired still gets the seats if nobody has claimed them, because an expired hold keeps its hold_id until another buyer takes the seat or the sweeper resets it. So give the sweeper a grace period of a few minutes past expiry. You can check expiry in step 4 instead. That rule is stricter, and it refunds more buyers who paid a little late.
The waiting room
The waiting room is a line in front of the booking flow. It caps how many people are shopping at once, and it replaces the 100,000 seat map requests per second with small "where am I in line" checks that never touch the seat tables.
Ticketmaster's help page on its queue describes this shape. People who join the waiting room before the sale starts are given a place in line when it begins. Ticketmaster says it only lets more people in when space opens up, and that queues may close to stop fans waiting for limited tickets at the end of a sale.
Size the cap from the sell-through time you want. Take Ticketmaster's hour as the target, which means the 4.6 orders per second from the estimates. Assume shoppers spend about 4 minutes from admission to payment or exit, and that half of them complete an order, since the seats they want may be gone or priced too high.
- If half of admitted shoppers buy, the line must admit 4.6 ÷ 0.5 = 9.2 shoppers per second.
- At 4 minutes each, the cap is 9.2 × 240 seconds ≈ 2,200 active shoppers.
- If only a quarter buy, the same target needs 4.6 ÷ 0.25 = 18.4 admissions per second, and 18.4 × 240 ≈ 4,400 active shoppers.
- Shoppers who buy nothing still try holds. At 3 attempts each, 9.2 × 3 ≈ 28 hold writes per second, still small for one database.
- The 2,200 shoppers refreshing the seat map every 5 seconds send 2,200 ÷ 5 = 440 requests per second, and 440 × 6,250 bytes = 2.75 MB per second.
The purchase rate changes the cap a lot, so measure it in the first minutes of the sale and adjust.
A larger cap sells out faster but puts more people on the same seats at once, so more holds fail and buyers retry. A smaller cap protects the booking path and makes the line slower. Pick the cap from a load test of the hold and payment path, and lower it when booking latency rises.
Admit by inventory as well as by load. When no seats are available and none are held, close the line and tell people. While holds are still outstanding, keep a short line, because expired holds put seats back on sale.
Fairness and bots belong here too. Ask the interviewer whether everyone who arrives before the start time is treated the same, or whether arrival order counts. Tie each place in line to a logged-in account, one place per account per event. For the Eras Tour Verified Fan on-sale, Ticketmaster said only verified buyers could enter the queue, and that verification was tied to the account and checked at login.
Seat map traffic
Behind the waiting room, the seat map is still the heaviest read, and it can be stale. Serve the layout from a CDN with a long cache time. Serve the 6,250-byte availability bitmap from a cache that rebuilds it once a second for each hot event. However many requests a second arrive, the database then sees one read a second per event. The rebuild marks a seat free when its status is available or its hold has expired, the same test the hold query uses, so a seat whose hold has lapsed shows as free before the sweeper resets it.
A stale map shows a seat as free after someone holds it. The buyer clicks, the conditional update matches zero rows, and the client marks the seat taken. That costs one failed click. It cannot cause a double sale, which is why the map is safe to cache.
Pushing changes to clients with server-sent events or WebSockets is a reasonable follow-up. It shortens the window in which a buyer clicks a seat that is already gone. Polling a cached bitmap is cheaper to build and run, so start with polling and add push if the interviewer asks about freshness.
General admission
With no assigned seats, you can keep one counter per price tier:
UPDATE ga_inventory SET remaining = remaining - $3
WHERE event_id = $1 AND tier = $2 AND remaining >= $3;
As a rough judgement, one counter row per tier keeps up at about 5 orders per second, because each update holds the row lock only briefly. Splitting it into several counters pays off once updates start queueing on that row. The catch is expiry. A counter has no expiry time on it, so a sweeper has to add expired holds back, and its lag keeps tickets off sale.
The other option is one row per ticket with no seat number. That reuses the seat hold design unchanged, including the expiry check in the WHERE clause and the SKIP LOCKED query. If you have already built seat holds in the interview, this is the cheaper answer to give.
Practice
Run the numbers again for a different sale before the interview. Take a 20,000-seat arena that should sell out in 10 minutes, and work out the order rate, the hold writes per second and the waiting room cap. Then decide whether one database and the same hold design still fit.
CramHQ's free assessment is about 15 minutes of system design scenario questions, needs no credit card, and then shows which design decision areas are your weakest. The Pass Plan costs USD 49.99 once for 12 months of access. It adds targeted repair work, a Readiness Exam and 16 Decision Drills, including one on consistency and concurrency.
