Design a Ticket Booking System
Design Ticketmaster-style booking: show live seat maps, hold seats for a few minutes, take payment, and never oversell, even when two million fans arrive at once for 60,000 seats.
🎯 What you'll learn
- Why contention, not volume, is the hard part
- Seat holds with conditional updates and expiry
- Payment flows with idempotency
- Virtual waiting rooms and bot defences
📚 Before you start
1Requirements
Functional
- Browse events and venues; see a seat map with live availability.
- Select specific seats (or general admission quantities) and hold them for a few minutes while paying.
- Pay and receive tickets; release holds automatically if payment doesn't complete.
- Handle huge on-sales (a popular concert) fairly.
Non-functional
- No overselling: a seat can be sold once. Correctness beats availability here.
- Seat map reads are massive during on-sales; booking writes are concentrated on a few events.
- Fairness and bot resistance for hot events.
2Estimation
- Normal day: 10k events, a few hundred bookings per second across all events.
- Hot on-sale: 2 M people try to buy 60k seats in the first minutes. Reads (seat map refreshes) can hit hundreds of thousands per second; writes concentrate on one event's 60k rows.
- The hard part isn't volume overall; it's contention on a small, hot set of rows.
3API
GET /v1/events/{id}/seats → seat map with status (available/held/sold), cached ~1 s
POST /v1/events/{id}/holds { seatIds[] } → 201 { holdId, expiresAt } · 409 some seats unavailable
POST /v1/holds/{holdId}/checkout { paymentToken, idempotencyKey } → 200 { orderId, tickets[] }
DELETE /v1/holds/{holdId} → release early4Holding Seats Without Overselling
The core is a seat state machine: available → held (with expiry) → sold, or held → available on expiry/cancel.
| Approach | How | Trade-offs |
|---|---|---|
| Pessimistic locking | SELECT … FOR UPDATE on the seat rows, update to held | Simple and correct; locks queue up under heavy contention |
| Conditional update | UPDATE seats SET status='held', hold_id=?, expires_at=? WHERE id IN (...) AND (status='available' OR expires_at < now()) and check the affected row count | Correct, no long locks, naturally handles expired holds; all-or-nothing needs a transaction |
| Redis hold keys | SET seat:{id} holdId NX PX 600000 per seat; DB updated on purchase | Very fast holds with automatic expiry; DB remains the source of truth at checkout |
A robust combination: holds via conditional updates in the database (or Redis NX keys with TTL for extreme on-sales), and the final sale is a conditional transition held(by this hold) → sold in the same transaction as the order record. Expired holds are released lazily (the condition treats them as available) and by a sweeper.
5Payment and Consistency
Payment is an external call that can be slow or fail ambiguously:
- Create the order in
pendingstate linked to the hold. - Call the payment provider with an idempotency key.
- On success: in one transaction mark seats
sold, orderpaid, issue tickets. If the hold expired meanwhile but the seats are still unsold, extend; if they were sold to someone else, refund automatically. - On ambiguous failures (timeouts), query the provider or wait for its webhook before deciding.
6Surviving the On-Sale: Virtual Waiting Room
Letting 2 M people hit the booking path at once melts the database and favours bots. Instead:
- Put everyone in a virtual queue at the edge (CDN/edge workers + a fast store); assign random or arrival order positions.
- Admit users to the booking flow at a controlled rate (e.g. a few thousand per minute) with a signed, short-lived access token.
- Serve the seat map from cache with ~1 s staleness; holds are the source of truth (a stale "available" seat simply returns 409).
- Bot defences: CAPTCHAs, rate limits per account/card/device, verified fan pre-registration, purchase limits per account.
⚠️ Common Pitfalls
- Check-then-update (
SELECT availablethenUPDATE) without a condition or lock: two buyers get the same seat. - Holding seats only in application memory: lost on restart, inconsistent across instances.
- No hold expiry: abandoned carts block inventory forever.
- Calling the payment provider inside a DB transaction holding seat locks.
- No waiting room: on-sales become a DDoS on your own database.
📋 Quick Reference
seat states: available → held(expires) → sold · conditional UPDATE … WHERE status='available' OR expired
hold TTL ~5–10 min · lazy expiry + sweeper · checkout: idempotent payment → txn(seats sold + order paid)
hot on-sale: virtual waiting room → admit at fixed rate → cached seat map (1 s) → holds are truth
bots: CAPTCHA, per-account limits, verified fans · contention, not volume, is the problem🧠 Key takeaways
- The problem is contention on a few hot rows, not total volume.
- Holds use conditional updates (or locks /
SET NX) with expiry. - Pay outside the seat transaction with an idempotency key, then commit the sale.
- Protect on-sales with a virtual waiting room, cached seat maps and bot limits.
🎤 Interview Q&A
Q1How do you prevent selling the same seat twice?core▾
Make the hold an atomic conditional write: UPDATE seats SET status='held', hold_id=?, expires_at=? WHERE id=? AND (status='available' OR expires_at < now()) and check rows affected (or SELECT … FOR UPDATE, or Redis SET NX with TTL). The sale is another conditional transition from held-by-this-hold to sold.
- Why not check availability first, then update? Two requests can both see 'available' before either writes.
Q2How do holds expire?core▾
Each hold has expires_at. Expired holds are treated as available by the hold condition (lazy expiry), and a background sweeper resets them for accurate seat maps. With Redis, key TTLs expire holds automatically.
- What if payment finishes after expiry? Re-check: extend if the seats are still free, otherwise refund automatically.
Q3How do you handle a massive on-sale?core▾
A virtual waiting room at the edge admits users at a controlled rate with signed access tokens; the seat map is cached for a second; booking capacity is sized for the admission rate; bots are limited with CAPTCHAs, per-account limits and verified fan programs.
- Why not just autoscale? The bottleneck is contention on one event's rows, which scaling app servers doesn't fix.
Q4Why shouldn't the payment call happen inside the seat transaction?senior▾
External calls are slow and can hang; holding row locks during them blocks other buyers and risks deadlocks. Hold seats first, pay outside the transaction with an idempotency key, then commit the sale.
- Ambiguous payment timeout? Query the provider or wait for the webhook before releasing seats.
Q5How would you support general admission (no seat numbers)?core▾
Track a counter of remaining tickets and decrement atomically with a condition (remaining >= qty), or split inventory into buckets/shards to reduce contention on one counter, with holds as reservations against the count.
- Why buckets? A single hot counter row serializes all purchases.
⏱ 45-Minute Mock Interview
Run this design against the clock, out loud. Each phase lists what to cover and what interviewers typically push on.
- Seat map, hold seats for minutes, pay, issue tickets, handle hot on-sales fairly
- Never oversell; fairness; bot resistance
- 2 M fans for 60k seats in minutes: contention on a few hot rows, not total volume
GET /events/{id}/seats,POST /holds,POST /holds/{id}/checkout- Seats table with status, hold_id, expires_at; orders
- Waiting room → booking API → cached seat map → conditional holds in seat inventory → payment → commit
- How does a hold work?
UPDATE … WHERE status='available' OR expired, check affected rows.
- Concurrency control options
- Hold expiry and payment races
- Virtual waiting room and bot defences
- Payment succeeded but the hold expired. Now what? Re-check seats: extend if still free, otherwise refund automatically.
- Why not autoscale to absorb the on-sale? The bottleneck is row contention; admission control is the fix.
- Seat map cache staleness
- Metrics: hold conflicts, checkout success, queue wait
🛠️ Practice exercises
Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.
✅ Check yourself
Answer the questions — feedback is instant.
UPDATE … WHERE status='available'. What happens?