Ticket booking
A booking system sells a fixed, tiny inventory to a huge, impatient crowd. The invariant is one seat, one buyer. The standard design keeps seats as rows in a transactional database and moves them available → held → sold with conditional updates, so the database, not the app, settles every race. Holds carry an expiry checked at read time, payment runs against a hold that can’t expire mid-charge, and a virtual waiting room admits fans at the rate checkout can absorb. Each booking is O(seats in the order) row writes; the hard part is contention on a few hot rows, not data size.
The problem
Section titled “The problem”A stadium has 50,000 seats. At 10:00, two million people want them, and most want the same few hundred near the stage. The system has to pick winners fairly, never sell a seat twice, and stay up.
| Goal | What it means | Pulls toward |
|---|---|---|
| No double booking | Each seat is sold at most once, even under a stampede | Serialised writes per seat, transactions |
| No phantom sell-outs | Seats held by people who walked away come back quickly | Short holds, expiry checked at read time |
| Survive the spike | Millions of requests in minutes, for a few minutes | Queueing at the edge, admission control |
| Fairness | Bots and refresh-spammers don’t win by default | Waiting room, per-user limits, verified fans |
The first goal is absolute. The others are trade-offs you tune, mostly through the hold TTL and the admission rate.
Requirements and scope
Section titled “Requirements and scope”| In scope (typical) | Ask about | |
|---|---|---|
| Functional | Browse events, see a seat map, hold specific seats (or N general-admission tickets), pay, get tickets. Release holds on timeout or cancel. | Reserved seating, general admission, or both? Max tickets per order and per person? |
| Scale | Catalogue browsing: steady, cacheable. On-sale: 1-10M fans for 10k-100k seats in minutes. | How spiky? How many on-sales at once? |
| Latency | Hold in under 500 ms at p99. Seat map can lag by a second or two. | How fresh must the seat map be? |
| Consistency | Strong for seat state: no seat sold twice. Eventual for maps, counts, search. | Is overselling ever acceptable (airlines do it on purpose)? |
| Availability | The on-sale must not fall over; degrade by queueing, not by erroring. | What happens to queued fans if inventory runs out? |
| Out of scope | Payments internals, resale marketplace, dynamic pricing, ticket transfer, fraud models. |
Back of the envelope
Section titled “Back of the envelope”What to say out loud: “The data is tiny, 50k seat rows fit anywhere. The write rate a database must handle is bounded by checkouts, a few hundred per second. The danger is the read and retry storm from millions of fans who will mostly lose, so I’ll absorb them at the edge with a waiting room and admit at the rate checkout can sustain.”
API and data model
Section titled “API and data model”GET /events/{id}/seatmap -> sections, seats with status (cached, ~1-2 s stale)POST /events/{id}/holds Authorization: <admission token> { seats: ["A11","A12"] } or { section: "GA-floor", quantity: 2 } -> 201 { hold_id, seats, expires_at } | 409 { unavailable: ["A12"], seatmap_version }POST /holds/{hold_id}/checkout Idempotency-Key: <hold_id> { payment_method } -> 200 { order_id, tickets } | 410 hold expired | 402 payment declinedDELETE /holds/{hold_id} -- release early (fan changed their mind)
Records: seat { event, seat_id, section, price_tier, status: available | held | paying | sold, hold_id?, expires_at?, version } -- PK (event, seat_id) hold { id, event, user, seat_ids, created_at, expires_at, status } order { id, hold_id (unique), user, amount, payment_ref, created_at } ga_inventory { event, section, total, sold, held } -- for general admissionTwo decisions hide in that sketch:
- Seat state lives on the seat row, not only on the hold. That makes “is A12 free?” a single-row read, and lets one conditional update do the check and the claim together. The hold row is the receipt.
hold_idis unique onorder. It doubles as the payment idempotency key, so a double-clicked Pay or a retried checkout can create at most one order and one charge. See Payment system.
High-level design
Section titled “High-level design”| Component | Role | Why it’s there |
|---|---|---|
| Waiting room | At the edge: queues fans, issues signed admission tokens at a controlled rate. | Converts a spike of millions into a steady stream the backend can serve. |
| Booking API (×N) | Validates tokens, places holds, runs checkout. | Stateless; scales out; any node can serve any fan. |
| Seat map cache | Read-mostly availability per event, refreshed on change. | 99% of traffic is looking, not buying. Reads never touch the primary. |
| Inventory DB | Seats, holds and orders, partitioned by event. Source of truth. | Row-level locks and transactions settle every race. |
| Payments | External or internal service, called with the hold id as idempotency key. | Money must move once per hold, however many times Pay is clicked. |
| Hold sweeper | Periodically resets expired holds and refreshes counts. | Housekeeping only; correctness already comes from the expiry check. |
| Order events | Queue for ticket issuing, email, analytics. | Slow work off the hot path. |
Deep dive: one seat, many buyers
Section titled “Deep dive: one seat, many buyers”Every design here is about one race: two buyers see A1 free, both try to take it. Run the strategies below. Set “arrivals spread” to 0 for a flash sale.
| Strategy | Double books? | Under contention | Best when |
|---|---|---|---|
| Read, then write (no lock) | Yes | Looks fast; is wrong | Never for inventory |
Pessimistic: SELECT ... FOR UPDATE, then update |
No | Buyers queue on hot rows; each waiter holds a connection | Contention is high and transactions are short |
| Optimistic: version check on write | No | No waiting, but losers redo work; retries spike on hot seats | Contention is low or moderate |
Conditional update (UPDATE ... WHERE status = 'available') |
No | The row lock lasts one statement; losers learn at once | The default for holds: atomic, one round trip |
| Distributed lock (Redis) per seat | Only if the lock expires mid-write | Adds a system and a failure mode | Rarely worth it when the DB already locks rows |
The conditional update is optimistic concurrency without a version column: the WHERE clause is the check. At the default READ COMMITTED level, Postgres waits for a concurrent writer and then re-evaluates the condition against the latest row version, so exactly one of two racing updates matches. (Under REPEATABLE READ the loser gets a serialization error to retry instead.) The Concurrency page covers check-then-act races in general.
place_hold(event, seat_ids, user, ttl): hold_id <- new id txn: n <- UPDATE seat SET status = 'held', hold_id = hold_id, expires_at = now + ttl, version = version + 1 WHERE event = event AND seat_id IN seat_ids AND (status = 'available' OR (status = 'held' AND expires_at <= now)) if n != len(seat_ids): ROLLBACK -- all seats or none return 409 with the seats that are gone INSERT hold (hold_id, event, user, seat_ids, expires_at = now + ttl) publish "seatmap changed" (async) ; return 201Details that come up:
- All or nothing. A fan who asked for 4 seats together doesn’t want 3. The rows-updated count decides, and the rollback releases the partial claim.
- Lock order. If you lock several seats with
FOR UPDATE, lock them in a fixed order (by seat id), or two buyers taking overlapping seats can deadlock. - Never hold a database lock across a payment. A lock that waits on a card network for 3 seconds serialises your on-sale. The hold status is the long-lived reservation; row locks last milliseconds.
- General admission is the same idea on a counter:
UPDATE ga_inventory SET held = held + n WHERE sold + held + n <= total. One row takes every write for the section, so split big sections into several counter rows (buckets) and try another bucket on failure.
Deep dive: holds, expiry and payment
Section titled “Deep dive: holds, expiry and payment”A hold is a lease on a seat: you own it until expires_at. Its TTL is a trade-off between buyers who abandon (and lock seats away from others) and real buyers who are slow (and lose seats they were about to pay for). Change the TTL and the reclaim mode.
expires_at at read time makes seats reusable the instant they expire; a sweeper alone leaves red gaps until it runs.What to take from it:
| Choice | Too little | Too much |
|---|---|---|
| Hold TTL | Slow buyers lose seats at the payment page | Abandoned holds make the show look sold out; fans turned away |
| Sweeper frequency (if it’s the only reclaim) | Dead seats sit unusable between runs | Load on the DB for little gain |
| Payment extension | Charged, then the hold is gone: refunds and angry fans | A stuck payment pins seats; cap it with the payment timeout |
The fix for the sweeper problem is to make expiry part of the read and write conditions (expires_at <= now counts as available, as in the hold pseudocode above). A seat is reusable the instant its hold expires; the sweeper only tidies up. See TTL and expiry.
checkout(hold_id, payment_method): txn: n <- UPDATE seat SET status = 'paying', expires_at = now + payment_timeout + grace WHERE hold_id = hold_id AND status = 'held' AND expires_at > now if n == 0: return 410 hold expired -- nothing charged yet: a clean failure result <- payments.charge(amount, idempotency_key = hold_id) if result is declined: release the hold (or return it to 'held' for one retry); return 402 txn: n <- UPDATE seat SET status = 'sold' WHERE hold_id = hold_id AND status = 'paying' INSERT order (hold_id unique, ...) -- a retry hits the unique key, not a 2nd order if n == 0: refund(result) and alert -- should not happen; never sell a lost seatClocks matter here. Use the database’s now() for every expiry comparison, so app servers with skewed clocks can’t disagree about whether a hold is alive. Show the fan a countdown a few seconds shorter than the real expiry.
Deep dive: the flash sale and the waiting room
Section titled “Deep dive: the flash sale and the waiting room”The database can take a few hundred to a few thousand checkouts per second. Two million fans arriving at once would overwhelm everything in front of it first: load balancers, API nodes, the seat map. The answer is to queue before the system, not inside it.
- Pre-queue. Fans who arrive before the on-sale wait on a static page served from a CDN. At the start time, they are put in random order, so arriving at 9:58 isn’t worse than 9:59 and refresh-spam doesn’t help. Later arrivals queue in arrival order.
- Signed tokens. Each fan gets a token with their position, signed by the waiting room. Progress polling hits the edge, not the backend. Cloudflare’s Waiting Room, for example, runs on its edge and lets you configure total active users and new users per minute.
- Admission control. The room admits fans at a rate the booking path can sustain, and adjusts it from live signals: checkout error rate, latency, DB load. This is rate limiting applied to people.
- Admitted sessions expire. An admission token is valid for, say, 15 minutes and one event, and the booking API checks it on every call. Per-account ticket limits stop one token buying the venue.
- Close the queue early. When inventory (including active holds) is gone, tell everyone still waiting. Released holds can go to a waitlist.
Hot partitions. Partition seat rows by event, so one event’s seats live together and a hold is a single-partition transaction. A mega on-sale makes that one partition hot. Spread it by section: each section is independent (a hold never spans sections in practice, or you restrict it to), so sections can live on different shards and be served in parallel.
Seat map freshness. Recompute the map from change events and push deltas, or serve it from a cache with a 1-2 s TTL. A stale map causes some 409s, which is acceptable; the write path is always authoritative. Show “best available” seat selection as an option, so most fans skip the map entirely and the server picks seats with fewer conflicts.
Failure modes
Section titled “Failure modes”| What breaks | What you see | Design response |
|---|---|---|
| Two holds race for a seat | One gets 0 rows updated | Conditional update; 409 with fresh availability |
| Fan abandons | Seats held, nobody pays | Short TTL; expiry checked in the hold condition; sweeper tidies |
| Payment slower than the hold | Charged for a seat you no longer have | Move to paying and extend before charging; refund if confirm fails |
| Double-click on Pay | Two checkout requests | hold_id as idempotency key and unique on order |
| On-sale spike | Edge and API saturate | Waiting room with admission rate tied to checkout capacity |
| Hot event partition | One shard at 100% | Split by section; best-available assignment spreads writes |
| Bots | Inventory gone in seconds to scalpers | Verified-fan presale, CAPTCHA at queue entry, per-account limits |
| DB primary failover | Holds fail for seconds | Clients retry holds (idempotent by hold id); queue pauses admission |
| App server clock skew | Holds appear alive or dead inconsistently | Compare against the database’s clock only |
What it costs
Section titled “What it costs”| Operation | Time | Space |
|---|---|---|
| Seat map read | O(1) from cache; O(seats) to rebuild for one event | ≈50 B × seats per event (50k seats ≈ 2.5 MB) |
| Place a hold | O(k) row updates for k seats, one transaction, ~1-5 ms | one hold row + k seat updates |
| GA hold | O(1) counter update | one row per section bucket |
| Checkout | 2 short transactions + 1 payment call (≈1-3 s) | one order row, k tickets |
| Expire holds | O(1) per check at read time; sweeper O(expired) with an index on expires_at |
index on (status, expires_at) |
| Waiting room | O(1) per poll at the edge | one signed token per fan, no server state needed |
| Throughput bound | ≈ checkouts/s the DB sustains (hundreds to low thousands per partition) | — |
In the interview
Section titled “In the interview”- Clarify (5 min). Reserved seats or GA, tickets per order, scale of the biggest on-sale, overselling never allowed, seat map freshness.
- Data model and the invariant (5 min). Seat rows with status, hold id, expiry, version. State the invariant out loud: one seat, one buyer.
- Hold and checkout (15 min). Conditional update for holds, all-or-nothing, 409 on conflict. Checkout with a
payingstate and hold id as idempotency key. Compare pessimistic and optimistic locking when asked. - Expiry (5 min). TTL trade-off, expiry in the condition, the sweeper as housekeeping, what happens if payment outlives the hold.
- The on-sale (10 min). Waiting room, randomised pre-queue, admission rate tied to capacity, bots, partitioning by event and section.
Follow-ups to expect: “Why not Redis locks for holds?” The database already serialises row writes; a second system adds a lock that can expire mid-write unless you add fencing tokens. Redis can hold soft holds for GA at extreme scale, with the DB as the final check. “A seat shows free but the hold fails.” Expected: the map is a cache; return fresh availability with the 409. “How do you make the queue fair?” Randomise early arrivals, one position per verified account, and don’t let refreshes create new positions. “Overbooking like airlines?” That’s a business choice: sell slightly beyond capacity on a counter and compensate; not for numbered seats.
Check yourself
Section titled “Check yourself”Go deeper
Section titled “Go deeper”- PostgreSQL docs: The Locking Clause (FOR UPDATE, NOWAIT, SKIP LOCKED): exact semantics of row locks, waiting and skipping.
- PostgreSQL docs: Explicit Locking: row-level locks, deadlocks and lock ordering.
- Martin Kleppmann: How to do distributed locking: why a lock with a timeout needs fencing tokens, and when a database is the better lock.
- Cloudflare: Cloudflare Waiting Room: queueing fans at the edge with configurable active users and admission rate.
Concept pages to drill: Concurrency · TTL and expiry · Versioning · Idempotency · Rate limiting · Resource allocation · State machine