Primary concepts and the hard part
Requirements
Normal traffic: 1,000 req/sec On-sale spike: 100,000 req/sec for ~60s ← the design driver Seats per big event: 50,000 Concurrent buyers: 500,000 chasing 50,000 seats (10:1 oversubscription)
API / Model
The state field on seats plus a version column is the whole concurrency-control story. Everything else is supporting cast.
High-level architecture
Buyers reach the application only through the virtual waiting room, which queues arrivals in a Redis sorted set under signed queue tokens and admits them at a controlled rate, about 1,000 per second, each for roughly 10 minutes. Behind the API Gateway, admitted users browse a deliberately stale Redis seat map and book against the Primary DB, which is sharded by event.
- With a valid queue token, the API Gateway passes a hold request to the booking path. The transaction locks the chosen seats with
SELECT … FOR UPDATE, taking the locks in sorted order. If every seat is stillAVAILABLE, it marks themHELDwithheld_byandexpires_at, inserts the hold and commits. If any seat is gone, it rolls back and returns 409 with nearby alternatives. - Within the 10-minute hold, checkout runs as a saga that verifies the hold, charges the buyer, marks the seats
SOLDand issues tickets. - Every seat change in the Primary DB is published to Kafka
seat.events, which invalidates the cached seat map and pushes live seat updates over WebSocket.
The browse path never takes a lock. Seat maps come from the CDN as static pages and from the Redis seat map, which has a TTL of 2-5 seconds and is refreshed by those seat events. Beside the booking path, the hold expiry sweeper scans the Primary DB and returns seats from expired holds to AVAILABLE.
Same product at 10x, roughly what the biggest stadium tours have drawn: millions of people in the queue at once for dozens of shows. 100x would put 50M people in one queue, more than any on-sale has seen, so 10x is the realistic tier. Dashed outlines mark what's new or reshaped compared with today's design.
| Today | At 10x | |
|---|---|---|
| Buyers at on-sale | 500k | ~5M |
| Request spike | 100k/sec | ~1M/sec |
| Seats on sale at once | 50k, one show | ~2M, a 40-show tour |
| Central writes per arrival | 1 Redis sorted-set insert | none |
What changes, and the number that forces it
- The waiting room moves to the edge. With ~5M arrivals in the first seconds, the waiting room's own Redis sorted set becomes the thing that falls over. The CDN edge issues each arrival a signed token carrying a random queue number (random rather than arrival time, so network speed doesn't decide the order), and nothing central is written. Admission becomes one number per show, the admit cursor: tokens below it get in, and it rises at the rate booking can absorb. Each fan's position is computed at the edge from their own token.
- Presale registration flattens the spike. The lottery from the fairness follow-up becomes the default for big tours. Verified fans register days before, codes go to a chosen subset, and the on-sale starts with a known audience instead of an unknown stampede. Bot filtering happens at registration, where it doesn't cost real buyers their conversion.
- Buyers pick a section; the server picks seats. With millions of people choosing individual seats, most hold attempts collide on the same few best seats: row locks wait, and every 409 turns into an immediate retry. Best-available allocation lets the buyer choose a section and price, and one allocator per section hands out the best remaining contiguous seats serially. Contention moves from competing row locks to an orderly queue in front of an allocator that never conflicts with itself. The cost is less choice; pick-your-seat can reopen once the rush drains.
- Browsing shows counts, not seat maps. Pushing live per-seat maps to millions of screens is a bigger fan-out than the sale itself. Admitted users see available seats per section and price, updated every few seconds from the seat event stream.
- Checkout queues payments and extends holds. Selling ~2M seats in minutes runs into processor rate limits. Checkouts wait in a payment queue, and a hold's TTL is extended while its checkout is waiting there, so a slow processor never releases seats someone is paying for.
- Each show is its own isolated on-sale. A 40-show tour is 40 simultaneous on-sales. Sharding by event already isolates their data; now each show also gets its own admit cursor and allocators, so a sold-out Saturday can't slow down Tuesday.
What stays the same
A seat can never be double-sold: holds are still ACID writes against Postgres, and hold expiry is still checked by both the sweeper and at read time. Display stays eventually consistent while the purchase stays strongly consistent, checkout is still a saga that retries issuance after a successful charge, and admission control still sits in front of the application tier. There is still no eventual consistency anywhere in the purchase path.
Trade-offs and deep dives
Without it, 500,000 concurrent requests hit 50,000 rows and the database dies on lock contention — not on CPU, on waiting. The waiting room converts an uncontrolled stampede into a controlled admission rate matched to what the booking tier can actually process. It also gives users an honest experience (a queue position and ETA) instead of an error page, and it makes the system's load predictable rather than a function of how popular the event turned out to be.
This is admission control from Module 8, and recognizing that it belongs before the application tier rather than inside it is the senior insight.
PESSIMISTIC (SELECT ... FOR UPDATE)
Lock the rows, then check and update. Other transactions block.
✓ No wasted work, no retry storms
✓ Correct under heavy contention ← the case here
✗ Holds locks; risk of deadlock if lock order varies
OPTIMISTIC (version column, compare-and-swap)
UPDATE seats SET state='HELD', version=version+1
WHERE seat_id=? AND version=? AND state='AVAILABLE'
Check rows-affected: 0 means someone beat you.
✓ No locks held, great when conflicts are RARE
✗ Under 10:1 oversubscription, almost everyone loses and retries
→ retry storm makes contention worseFor a hot on-sale, pessimistic wins, because conflicts are the norm rather than the exception and optimistic retries amplify load exactly when you can least afford it. Optimistic is fine for ordinary, low-contention bookings. Being able to say "which one depends on the conflict rate, and here the conflict rate is enormous" is the answer they're looking for.
When holding multiple seats, always acquire locks in a consistent order (sort seat IDs). Two transactions grabbing seats A and B in opposite orders will deadlock. Sorting eliminates the cycle. Small detail, real bug, good signal.
A hold that never expires means an abandoned checkout permanently removes a seat from sale. Set hold_expires_at, run a sweeper to reclaim expired holds and check expiry at read/hold time. Relying solely on the sweeper means a brief window where an expired hold still blocks a sale; relying solely on read-time checks means expired holds linger in the data. Do both.
The seat map shown while browsing is a cached snapshot with a 2-5 second TTL. It will sometimes show a seat that was just taken. That is acceptable and unavoidable — any attempt to make the browse view perfectly accurate under 100k req/sec will destroy the database. The contract with the user is: the map is a hint, the truth is decided when you press "hold," and a 409 at that point is a normal, expected outcome that the UI must handle gracefully. Stating this boundary between "eventually consistent display" and "strongly consistent transaction" is the core trade-off of the design.
All contention for one event lands on one partition. That sounds bad, but it's actually the goal: it means a hot on-sale for one stadium show cannot degrade every other event on the platform. Blast-radius containment. Since the dataset per event is tiny, one partition handles it comfortably once the waiting room caps the arrival rate.
No seat map, just a counter. Use an atomic decrement (UPDATE ... SET sold = sold + 1 WHERE sold < total) or a Redis counter with a conditional. Much cheaper, no row-level contention, and worth distinguishing from reserved seating explicitly — they often ask about both.
Charge payment, mark seats sold, issue tickets. If payment fails, release the hold. If ticket issuance fails after a successful charge, do not release seats — retry issuance, because the customer has paid. Deciding which failures compensate and which retry is the substance of saga design, and it's worth walking through rather than just saying "saga."
At a real on-sale, most of that 500,000 is automated. Defences: queue tokens tied to authenticated accounts, device fingerprinting, per-account purchase caps enforced at hold time, CAPTCHAs on entry to the queue rather than at checkout (where they'd cost you real conversions), and rate limits per account. Mention it — a Ticketmaster design that ignores bots is missing the actual production problem.
Possible follow-up questions
AVAILABLE and gets a 409 with a suggestion of nearby alternatives. The UX of losing gracefully matters as much as the locking.AVAILABLE), and decide the product rule on whether returned seats go back on public sale or to a waitlist.