A stadium has 60,000 seats. When a major tour goes on sale, millions of people arrive in the same minute, all wanting the same few thousand good seats. In November 2022, Ticketmaster reported 3.5 billion total system requests during the Eras Tour presale, four times its previous peak. Storing 60,000 rows is trivial. Arbitrating millions of competing requests for those rows, fairly and without ever selling one seat twice, is the hard part.
This post designs a ticket booking system for that kind of onsale: requirements, the shape of the traffic, a virtual waiting room, seat holds with expiry, the booking transaction, payments, hot seats and bots, with an architecture diagram.
Requirements
Functional:
- Browse events and a seat map with availability.
- Select seats and hold them while paying.
- Pay, receive tickets, and release seats if payment does not happen.
Non-functional:
- Never sell a seat twice. This is the one rule that cannot bend.
- Treat people fairly: arrival order or a published random draw, not whoever refreshes fastest.
- Survive traffic one or two orders of magnitude above normal for the first minutes of an onsale.
- Fail fast and clearly: a quick "sold out" beats a spinning page.
The shape of an onsale
Assume 60,000 seats and 2 million people arriving within ten minutes of the start. That is about 3,300 new arrivals per second, and more than 30 people for every seat. No amount of capacity gives everyone a ticket. The system's real job is to let a sustainable number of people shop at a time, say no quickly to the rest, and keep the inventory correct under extreme contention.
That splits the design into two parts: a gate that absorbs the crowd, and a booking core that only ever sees as many shoppers as it can handle.
The virtual waiting room
Put a queue in front of the booking flow:
- Fans who arrive before the start get a random position among everyone who arrived early, so opening a hundred tabs at 9:59 does not help. Later arrivals queue in order.
- The waiting room is a static page served from a CDN, so millions of waiting browsers cost almost nothing.
- Fans are admitted in batches at a rate tuned to measured booking capacity, for example 2,000 per minute.
- Each admitted fan gets a signed, short-lived token tied to their session. The booking API rejects requests without a valid token.
Managed products such as Cloudflare Waiting Room and Queue-it do this. A home-grown version is a Redis sorted set with arrival scores, where the position is the member's rank:
ZADD queue:evt42 1793606400123 session:9f2c # score = arrival time in ms
ZRANK queue:evt42 session:9f2c # position, 0-based
ZPOPMIN queue:evt42 2000 # admit the next batch
For the early pool, assign random scores instead of arrival times. Either way, the queue turns an unbounded spike into a steady flow the rest of the system is sized for.
The inventory model
Reserved seating needs one row per seat:
| Column | Example | Purpose |
|---|---|---|
event_id, seat_id |
evt42, S112-R7-14 |
Identity; unique together |
status |
available, held, sold |
Current state |
hold_id, held_until |
h_81f, timestamp |
Who holds it and until when |
order_id |
ord_5521 |
Set when sold |
General admission is simpler: a counter per price zone, decremented with a conditional update, instead of individual seats.
Seat holds with expiry
A hold reserves seats for one shopper for a few minutes while they pay. The safest place for holds is the same database that records sales, using a conditional update:
UPDATE seats
SET status = 'held', hold_id = $3, held_until = now() + interval '10 minutes'
WHERE event_id = $1
AND seat_id = ANY($2)
AND (status = 'available' OR (status = 'held' AND held_until < now()));
-- If fewer rows changed than seats requested, roll back: someone else got one.
Two shoppers can send this statement for the same seat in the same millisecond. The database serializes the updates on that row, one of them changes it, and the other sees zero rows and gets a clear "seat just taken" answer. Expired holds are reclaimed in the same statement, so a hold's expiry never depends on a background job running on time. Two requests for overlapping sets of seats can occasionally deadlock; the database detects it and aborts one, so retry once on a deadlock error.
At very high scale, some teams put a fast filter in front: SET seat:evt42:S112-R7-14 h_81f NX PX 600000 in Redis, which sets the key only if it is absent, with a ten-minute expiry. It sheds load from the database, but Redis replication is asynchronous, so a failover can lose holds. Treat Redis as an optimization; the database still decides.
The last line of defense is a unique constraint on (event_id, seat_id) in the tickets table. Even if every layer above has a bug, the database refuses to issue the second ticket.
Hot seats and best available
Everyone wants the front rows, so thousands of requests target the same few hundred rows, and most of them fail. Three techniques reduce the contention:
- Best available. Instead of letting everyone pick from the map, let shoppers choose a section and quantity, and allocate seats server-side with
SELECT … FOR UPDATE SKIP LOCKED, so concurrent requests grab different rows instead of queuing on the same ones. - Partition the inventory by section, so different sections lock different rows and can live on different database shards.
- Cache the seat map with a short expiry and accept that it can be a few seconds stale. The hold, not the map, is the authority on availability.
Payment without double charges
- Create an order in
pendingstate linked to the hold. - Call the payment provider with an idempotency key derived from the order ID. With Stripe, for example, idempotency keys are up to 255 characters, and a retried request with the same key returns the original result instead of charging again.
- On success, in one database transaction, mark the seats sold, attach them to the order and mark the order paid.
- Listen for the provider's webhooks to catch the case where the payment succeeded but your service never saw the response.
Hold expiry must not race with payment. Extend the hold when payment starts, or exclude seats with a pending order from expiry. Otherwise a seat can expire, be sold to someone else, and then have its first payment succeed. The payment gateway integration diagram covers webhooks and reconciliation in detail.
Releasing expired holds
Holds expire lazily, through the held_until < now() condition above, and actively, through a sweeper that runs every few seconds and marks expired holds available so the seat map catches up. Do not build expiry on Redis keyspace notifications: Redis documents its Pub/Sub as fire and forget, so a subscriber that disconnects misses events.
Bots and abuse
The waiting room is the main defense, because it makes speed irrelevant. Add per-account purchase limits, rate limits per account, IP and device, registration before the onsale for high-demand events, and challenges in the waiting room. The rate limiter system design covers the limiting itself.
The ticket booking diagram
Diagram contents
- Fans
- Waiting room (static, on CDN)
- Queue positions + admissions
- API gateway: token check
- Booking service
- Seats, holds, orders
- Hold sweeper, every 5 s
- Payment provider
- order.confirmed events
- Ticket delivery
- arrive at 10:00
- ZADD / ZRANK
- batch + signed token
- admitted?
- hold seats, pay
- hold: conditional UPDATE, 10 min
- release expired holds
- charge + Idempotency-Key
- webhook
- order paid
- send tickets
How to read it:
- Fans arrive at the waiting room on the CDN, which holds them and admits batches with signed tokens.
- The API gateway and booking service see only admitted fans. Redis stores queue positions and admission state.
- Postgres holds the seats. The conditional update creates holds, and the unique constraint protects sales.
- The payment provider is called with an idempotency key, and its webhooks come back to the booking service.
- The sweeper function releases expired holds; the order queue feeds ticket delivery by email and in the app.
- The Markdown block lists the onsale numbers and the hold rules.
Drawing booking systems so reviewers find the races
- Draw the gate separately from the core. The waiting room and the booking service have different scaling models and owners.
- Write the hold duration and the uniqueness rule on the diagram. Those two facts explain the design.
- Put the idempotency key on the payment edge. It is the reason retries are safe.
- Show the expiry path. Where seats come back is where many double-sale bugs hide.
For a general method, see the system design diagram example. The order events can be published with the transactional outbox pattern, and ticket emails through a pipeline like the one in the notification system design.
Build it in Archboard
- Sign in with Google or GitHub and create a diagram.
- Add a Person from the C4 Model pack for fans and a CDN node from the Cloud pack for the waiting room.
- Add an API Gateway and a Compute node from the Cloud pack for the booking service.
- Add a Redis node from the Data pack for queue state and a Postgres node for seats, holds and orders.
- Add an External System from the C4 Model pack for the payment provider, a Function from the Cloud pack for the hold sweeper, a Queue for order events and another Compute node for ticket delivery.
- Label the edges with tokens, hold rules and the idempotency key, and add a Markdown block from the Core pack with the numbers.
The example uses 11 diagram nodes, within the Free plan.
FAQ
How do ticketing systems prevent double booking? With a conditional update on the seat row, so only one request can move a seat from available to held, and a unique constraint on the ticket table as the final safeguard. Caches and queues reduce load, but the database decides.
How long should a seat hold last? Long enough to pay comfortably and short enough that abandoned seats return quickly. Five to ten minutes is a common range. Extend the hold when payment starts so expiry cannot race with a successful charge.
What is a virtual waiting room? A queue in front of the booking flow that admits users at a rate the system can handle. It is usually a static page on a CDN, with positions assigned randomly for early arrivals and in order afterwards.
Should seat holds live in Redis or the database? The database should be the source of truth, because Redis replication is asynchronous and a failover can lose keys. Redis works well as a fast filter in front, as long as the database still enforces uniqueness.
How do you handle a payment that succeeds after the hold expired? Prevent it by extending holds during payment. If it still happens, refund automatically or offer an equivalent seat, and use the provider's webhooks to detect it.