Design a Ticket Booking System: The Whole Interview, From On-Sale Spike to Paid Seat
The ticket booking question is a contended-writes problem with a peak that dwarfs the average. Here is the full answer: the waiting room, seat holds with TTLs that survive races, the conditional update that replaces locks, the payment saga with a pivot, and how each foundation post plugs in.
Four posts on this blog used “design a ticket booking system” as the running example: the first five minutes ran the opening, the consistency post wrote the one-paragraph consistency answer, and the isolation post answered “two users book the last seat.” None of them finished the design. This one does, from the on-sale spike to the paid seat, and it points at each foundation post at the moment the design needs it.
The shape to keep in your head: this is a contended-writes system with a peak that is the entire problem. The average load is nothing. The first sixty seconds of a popular on-sale is everything.
The Question
The direct forms:
- “Design a ticket booking system for live events.”
- “Design Ticketmaster.”
- “Design seat reservation for a cinema chain.”
The same shape wearing different clothes: airline seat selection, hotel room booking, restaurant reservations, and limited-drop e-commerce. All of them are “many buyers, scarce inventory, a moment of contention, and a payment that must happen exactly once.” If you can answer this one, you can answer those with the nouns swapped.
What They Are Really Asking
- Can you find the hot row and handle it without a lock held across a network call? The seat is the hot row. Holding a database lock on it while a payment provider thinks for three seconds is the classic mistake.
- Do you know the difference between the display and the decision? The seat map that a hundred thousand people are staring at can be a second stale. The hold that commits a seat to a buyer cannot.
- Can you shape a spike you cannot absorb? The interviewer wants to hear “waiting room” and wants to hear why: fairness, bot control, and turning an unbounded burst into a rate you chose.
- Do you know what a hold is? A time-limited reservation with an expiry that is checked atomically, not a lock, not a cron job.
- Can you make the payment safe? Idempotent charge, the pivot of a saga, and a plan for the case where the provider says nothing.
- Do you connect the pieces? Consistency model per data item, isolation mechanism for the hold, idempotency for the charge, saga for the flow. This question is where the foundations meet.
The Gotchas
Gotcha 1: Designing for the average. Three purchases a second on average. A hundred thousand people in the first minute of a popular on-sale. If your design does not name the spike in the first five minutes, everything after it is sized wrong.
Gotcha 2: Locking the seat during payment. SELECT ... FOR UPDATE on the seat, then call the payment provider, then commit. The lock is held for the provider’s latency, which under load is seconds, and every other buyer of that seat, and every sweeper, and every seat-map refresh that touches the row, queues behind it. The hold is a state in the row with an expiry, not a lock, and the payment happens outside any database transaction.
Gotcha 3: Holds that expire by cron. A job runs every minute and releases stale holds. Between the hold expiring and the job running, the seat is dead inventory. Worse, the job releases a hold at the same instant the buyer completes payment, and now a seat is sold and released. Expiry is a timestamp checked atomically in the same conditional update that takes or sells the seat. The sweeper is cleanup, not the source of truth.
Gotcha 4: The seat map as the source of truth. The cached seat map says available; the buyer clicks; the database says no. That is fine, and it is the design, as long as the hold attempt is what decides and the interface handles the conflict gracefully. The gotcha is designing as if the map were authoritative.
Gotcha 5: No waiting room. A hundred thousand requests a second hit the API tier and the seat database at once. Even if the database survives, fairness is random and bots win. The waiting room turns the burst into a controlled admission rate and gives you a place to enforce one-per-person.
Gotcha 6: “Best available” as a serializable transaction. Selecting the best two adjacent seats by reading the map and then writing is write skew territory. The fix is not serializable; it is to pick candidates from the cached map and attempt a conditional multi-row update, then try the next candidates if it fails.
Gotcha 7: Charging twice. Client retries the purchase after a timeout. Without an idempotency key on the charge, two charges. The idempotency post is the whole answer; here it is applied.
Gotcha 8: The payment provider says nothing. Timeout. Did the charge happen? Retrying blindly may double-charge; giving up may lose a paid order. The answer is to query the provider by your idempotency key before deciding, and to treat the charge as the pivot of a saga.
Gotcha 9: General admission as read-modify-write. “Read available count, subtract, write.” Two buyers, one ticket, both succeed. General admission is a single conditional decrement, per the isolation post’s toolbox.
Gotcha 10: Pretending the hot event is not hot. Sharding by event puts the whole on-sale on one shard. That is unavoidable: the event is the unit of contention. Say it, then say how one shard survives it: single-row conditional updates at a few thousand per second, admission rate set below that, and partitioning by section if one event outgrows a shard.
How to Answer
Step 1: Requirements and numbers
The first-five-minutes post ran this in full. The summary of what it settled:
- In scope: browse events, view seat map, hold seats, pay, confirm. Reserved seating and general admission.
- Out of scope unless asked: refunds, resale, recommendations, venue management.
- Consistency: no double-selling, so the seat decision is linearizable. Browsing is eventual with a stated bound.
- Latency: seat map under 200 milliseconds, purchase under two seconds.
- Peak: a popular on-sale, 100,000 people on 50,000 seats in the first minute.
The numbers, per the back-of-envelope post:
| Quantity | Estimate | So |
|---|---|---|
| Average purchases | 100M tickets a year ≈ 3 per second | Irrelevant to the design |
| On-sale arrivals | 100,000 users in about 60 seconds ≈ 1,700 per second | The API tier and waiting room must absorb this |
| Seat-map reads at peak | 100,000 users refreshing every two seconds ≈ 50,000 per second | Served from cache or pushed, never from the database |
| Hold attempts at peak | Admitted users, one to three attempts each. With admission at 2,000 per minute, about 100 hold writes per second; with no waiting room, up to 5,000 per second on one event | A single shard handles the first easily and the second at its limit. The waiting room sets this number |
| Storage | 50,000 seats × 200 bytes per event is 10 MB; all events for a year, a few GB | Trivial |
The sentence: the database is not the problem. Admission rate and the hot row are the problem.
Step 2: API
1
2
3
4
5
6
7
8
GET /events/{id}/seat-map → sections, seats, status (cached, ~1s stale)
GET /events/{id}/queue → { position, admit_token? } (waiting room)
POST /events/{id}/holds Idempotency-Key, admit_token
{ seat_ids: [...] } → 201 { hold_id, expires_at } | 409 { unavailable: [...] }
POST /holds/{id}/purchase Idempotency-Key
{ payment_method_token } → 202 { order_id, status: PENDING } then poll or push
GET /orders/{id} → { status: PENDING | PAID | FAILED, seats }
DELETE /holds/{id} → release early
Two things to point at: the hold endpoint requires the waiting room’s admit token, and both mutating endpoints take an idempotency key. The purchase returns 202, not 200, because the charge is asynchronous from the client’s point of view.
Step 3: Data model
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
seats (
event_id uuid,
seat_id text, -- 'A-12'
section text,
price_tier text,
status text, -- AVAILABLE | HELD | SOLD
hold_id uuid, -- set while HELD; used as a fencing token when selling
hold_expires_at timestamptz,
version int,
primary key (event_id, seat_id)
)
holds (
id uuid primary key,
event_id uuid,
user_id uuid,
seat_ids text[],
expires_at timestamptz,
status text -- ACTIVE | PURCHASING | CONVERTED | RELEASED | EXPIRED
)
orders (
id uuid primary key,
hold_id uuid unique, -- one order per hold; the natural backstop
user_id uuid,
amount_cents int,
payment_ref text, -- provider's charge id
status text -- PENDING | PAID | FAILED
)
ga_inventory (
event_id uuid,
tier text,
available int,
primary key (event_id, tier)
)
The primary key on (event_id, seat_id) is the uniqueness guarantee. The hold_id on the seat is what lets the sale step prove it is selling to the right hold. The hold_id unique on orders is the second line of defense from the idempotency post: even if every key expires, one hold cannot become two orders.
Step 4: The architecture
flowchart LR
U[Buyers] --> CDN[CDN / edge]
CDN --> WR[Waiting room<br/>admission at N/min, signed token]
CDN -->|seat map| SMC[(Seat-map cache<br/>~1s TTL or pub/sub)]
WR --> API[Booking API]
API -->|conditional updates| SDB[(Seats DB<br/>sharded by event)]
SDB -.->|change stream| SMC
API --> ORD[Order service]
ORD -->|charge, idempotency key| PAY[Payment provider]
ORD --> ODB[(Orders DB + outbox)]
ODB -->|relay| Q[[Broker]]
Q --> N[Email, analytics, fraud]
SW[Sweeper] -->|release expired holds| SDB
Talk it through left to right: the edge absorbs the arrivals and serves the seat map from cache. The waiting room admits buyers at a rate the seat shard can take. The booking API takes holds with conditional updates. The order service runs the payment saga and publishes through an outbox. A sweeper cleans up expired holds that nobody touched.
Step 5: The hold, which is the deep dive
Compare the ways to reserve a seat before choosing:
| Approach | How | Contention behavior | Verdict |
|---|---|---|---|
| Row lock during checkout | FOR UPDATE, hold the transaction through payment |
Lock held for seconds under load; everything on that row queues | No |
| Cache lock with TTL, database behind it | Acquire a key with expiry in a cache, then write | Fast, but two sources of truth; cache and database can disagree after a failure | Only as a fast path in front of the real thing |
| Queue that serializes all writes per event | One consumer per event applies holds in order | Perfectly fair, no contention, but latency is the queue depth and the consumer is a single point | Viable for the largest events; overkill for most |
| Conditional update with an expiry in the row | One statement takes the seat only if it is free or its hold has expired | Contention is on the database’s row lock for microseconds; losers get a clear conflict | The default answer |
The conditional update, for a multi-seat hold, in one transaction:
1
2
3
4
5
6
7
8
UPDATE seats
SET status = 'HELD', hold_id = :hold_id, hold_expires_at = now() + interval '10 minutes',
version = version + 1
WHERE event_id = :event_id
AND seat_id = ANY(:seat_ids)
AND (status = 'AVAILABLE'
OR (status = 'HELD' AND hold_expires_at < now()));
-- if row count != number of seats requested: ROLLBACK, return 409 with the ones that failed
What to say about it:
- It works at read committed. The row lock the database takes for the statement’s duration is the only lock, and it is held for microseconds. No application-level locking, no serializable. The isolation post’s toolbox, applied.
- Expiry is inside the condition. A hold that has lapsed is taken by the next buyer atomically. No window where the seat is dead, and no race with a sweeper.
- All-or-nothing for multi-seat. Row count equals seats requested, or roll back. The buyer either gets the pair or neither.
- The sweeper is cleanup. It releases expired holds that nobody has tried to take, so the seat map reflects reality and the hold record moves to
EXPIRED. It is not what makes expiry correct.
Hold duration is a product decision with a technical cost. Say the trade-off:
| Hold TTL | Buyer experience | Inventory effect |
|---|---|---|
| 5 minutes | Rushed; payment failures cause loss of seats | Inventory recycles fast during the spike |
| 10 minutes | Comfortable for most checkouts | Standard |
| 15 minutes | Generous | Seats sit unsold in the window everyone wants them; more abandoned holds |
Default 10 minutes, with a short extension when the buyer enters the payment step, and no extension after that.
Step 6: The purchase, which is a saga
sequenceDiagram
participant C as Client
participant O as Order service
participant DB as Orders DB
participant S as Seats DB
participant P as Payment provider
C->>O: POST /holds/{id}/purchase Idempotency-Key: k
O->>DB: reserve key k (in_progress); INSERT order PENDING (hold_id unique)
O->>S: UPDATE holds SET status='PURCHASING', expires_at = now()+3m WHERE id=? AND status='ACTIVE' AND expires_at > now()
alt hold gone or expired
O-->>C: 409 hold expired
end
O-->>C: 202 { order_id, PENDING }
O->>P: charge(amount, idempotency_key = order_id)
alt declined
P-->>O: declined
O->>S: release seats WHERE hold_id=? ; hold RELEASED
O->>DB: order FAILED; outbox OrderFailed
else timeout / unknown
O->>P: GET charge by idempotency_key (retry with backoff)
Note over O,P: decide only once the provider answers
else charged (pivot)
P-->>O: ok, charge_id
O->>S: UPDATE seats SET status='SOLD' WHERE event_id=? AND seat_id=ANY(?) AND hold_id = :hold_id
alt row count short (hold was lost)
O->>P: refund(charge_id) — compensation, retry until success, then human
O->>DB: order FAILED; outbox
else all sold
O->>DB: order PAID, payment_ref; outbox OrderPaid (same transaction)
end
end
The points to make, each of which ties to a foundation post:
- The charge is the pivot. Before it, failure means release the hold. After it, failure means refund, which is a compensation that retries and then escalates. That is the saga post applied to one flow.
- The charge is idempotent. The order ID is the idempotency key sent to the provider. A retry after a timeout cannot double-charge. If the provider does not answer, query by that key before deciding anything.
hold_idis the fencing token. The sale only converts seats whosehold_idmatches. If the hold expired and someone else took the seat between the charge and the sale, the row count comes up short, and the design refunds rather than double-selling. This is the one place where the “lock during payment” instinct came from, and this is what replaces it.- Order and event in one transaction.
PAIDand the outbox row commit together. Email, analytics, and fraud checks are consumers with inboxes. Nothing downstream is on the hot path. - The hold gets a short extension when purchase begins, and none after. A buyer whose payment takes four minutes loses the seat and gets a refund, and that is the correct trade against inventory sitting idle during the spike.
Step 7: The on-sale spike
Now the part that decides whether the system is up at 10:00:01.
The waiting room. Buyers arrive at the edge and get a queue token. At the on-sale moment, the order of the first cohort is randomized, then admission is first-come after that. The waiting room admits at a fixed rate, say 2,000 per minute per event, and issues a signed, short-lived admit token that the hold endpoint requires. What this buys:
- The hold write rate is a number you chose, not a number the crowd chose.
- Fairness is explicit and explainable.
- It is the natural place for one-per-account, device checks, and a bot challenge, because the cost of a challenge is paid once at entry rather than on every request.
The waiting room itself is a counter and a token issuer, and it is the rate limiter post with a queue in front of it. Managed edge products offer it off the shelf; say that you would buy it unless the fairness rules are unusual.
The seat map. Fifty thousand reads a second never reach the database. The map is a cached document per event, refreshed from a change stream on the seats table, served with a one-second TTL from the edge. For admitted buyers, push updates over a persistent connection so they see holds and sales without polling. The map is a display; the hold is the decision. Buyers will occasionally click a seat that is already gone, and the 409 with the specific seats tells the interface to redraw.
The hot shard. The event is the unit of contention, so one event lands on one shard. At 100 conditional updates per second, which is what a 2,000-per-minute admission rate with a few attempts each produces, a single shard is bored. If a stadium event needs 10,000 admissions per minute, partition the seat table by section within the event so that the contention spreads across a few shards, and keep multi-seat holds within a section.
Step 8: Consistency, per data item
The consistency post’s paragraph, as a table:
| Data item | Model | Enforced by |
|---|---|---|
| Seat status when holding or selling | Linearizable on the seat row | Single-leader shard per event; the conditional update runs on the leader |
| Seat map for browsers | Eventual, about one second | Cache fed by the change stream; edge TTL |
| A buyer’s own hold and order | Read-your-writes | Route the buyer’s reads to the leader for a short window, or carry the version |
| Order status after payment | Read-your-writes, and durable before the 202 is answered | Orders database primary |
| Email, analytics, fraud | Eventual | Outbox relay and consumers |
Step 9: What you would buy, and what to skip
Buy the waiting room, the CDN, the payment provider, and the message broker. Build the hold logic and the order saga, because they are the product. Skip serializable isolation, distributed locks, and any design that holds a database transaction across the payment call.
Follow-Up Questions to Expect
- “Best available two adjacent seats?” Pick candidate pairs from the cached map, attempt the conditional multi-row hold, on conflict try the next pair. Three attempts server-side before telling the user. Never serializable, never a read-then-write.
- “General admission?” One row per tier with a count, and
UPDATE ... SET available = available - :n WHERE available >= :n. Row count tells you if it worked. Hot row, but it is one row and the statement is microseconds. - “The hold expires while the buyer is entering card details.” The purchase step extends the hold briefly and atomically, or fails with a clear message if it already lapsed. After the charge, the fencing token decides. The product answer is a visible countdown.
- “The payment provider is slow.” The purchase is already asynchronous: 202, then poll or push. The hold extension caps how long a slow provider can hold inventory. Beyond the cap, refund and release.
- “How do you stop bots?” At the waiting room: account limits, device and behavior signals, challenges at entry. Say it is an arms race and you would buy the edge product’s version rather than build it.
- “Multi-region?” Seats for an event live in one region, the event’s home. Browsing replicates everywhere. A buyer far from the home region pays cross-region latency on the hold, and that is acceptable for a two-second purchase budget.
- “How would you test the spike?” A synthetic on-sale with a hundred thousand simulated buyers against a staging event, plus a chaos test that kills the order service between the charge and the sale and confirms exactly one of refund or sold.
Key Takeaways
- Contended writes with a peak. Say the peak in the first minute.
- The seat map is the display and is eventual. The hold is the decision and is linearizable.
- A hold is a status and an expiry in the row, taken by a conditional update at read committed. Not a lock, not a cron job.
- Never hold a database transaction across the payment call.
- The charge is the pivot of a saga, idempotent on the order ID. Before it, release. After it, refund, retried then escalated.
hold_idis the fencing token that makes the sale safe after the charge.- The waiting room sets the write rate, provides fairness, and is where bot control lives. Buy it.
- One event is one shard by nature. Partition by section only if a single event outgrows it.
Further Reading
- Alex Xu, System Design Interview Volume 2, chapter on hotel reservation (the same shape)
- Cloudflare, Waiting Room documentation (what a managed waiting room actually does)
- Martin Kleppmann, Designing Data-Intensive Applications, chapter 7 on preventing lost updates and write skew
- Stripe, Idempotent requests (how a payment provider’s idempotency key behaves)
- The foundation posts this design leans on: first five minutes, numbers, consistency, isolation, idempotency, sagas and the outbox