Design Ticketmaster
Problem Statement#
"Design a ticket booking system like Ticketmaster. Users browse an event's seats, pick one, and buy it, and no seat can ever be sold to two people."
That's it. One sentence. The rest is yours to figure out.
Requirements Gathering#
This is where the interview actually begins. A strong candidate doesn't start drawing boxes. They ask questions. Not to stall, but because the answers fundamentally change the design.
Here's how a good requirements conversation looks:
Candidate: "Is this reserved seating with individual seats, or general admission where we just track a count?"
Interviewer: "Reserved seating. Each seat is a specific, assignable spot on a seat map."
Candidate: "Is overselling ever acceptable, even one seat, even under load?"
Interviewer: "Never. One seat, one owner. That's a hard constraint."
Candidate: "Do we hold a seat while the user pays, or go straight from selection to payment? And how long is the hold?"
Interviewer: "Hold it. Give them about 10 minutes to complete payment."
Candidate: "Are we designing for Taylor-Swift-level spikes, hundreds of thousands hitting the same on-sale at once?"
Interviewer: "Yes. 500,000 concurrent users when a hot event goes on sale."
Candidate: "What's the latency budget for completing a booking?"
Interviewer: "Confirmation within a few seconds once they submit payment."
Candidate: "Payment is a synchronous external call during confirm, right? Stripe or similar?"
Interviewer: "Yes, an external payment provider, called during the confirm step."
Candidate: "Out of scope: dynamic pricing, resale/secondary market, refunds?"
Interviewer: "Correct, out of scope. Just browse, hold, pay, confirm."
Functional and Non-Functional Requirements#
Functional Requirements#
- Users browse an event and see available seats on a seat map
- Users select a seat and hold it for up to 10 minutes while paying
- A held seat is unavailable to everyone else during the hold
- On successful payment, the seat is confirmed to the buyer
- If payment fails or the hold expires, the seat returns to inventory
- Users receive a booking confirmation (ticket, email)
Non-Functional Requirements#
| Requirement | Target |
|---|---|
| Correctness | No seat ever booked by two users, a hard constraint |
| Consistency model | CP: strong consistency for seat assignment (sacrifice availability if forced) |
| Booking latency | under 3s once payment is submitted |
| Peak load | 500k concurrent users; ~1,000 seat-holds/sec |
| Availability | 99.99% for browsing; booking prioritizes correctness over availability |
Out of scope: Dynamic pricing, resale/secondary market, refunds, push-notification ticket delivery
Capacity Estimates#
Work through the math. Don't just state the numbers.
The number that should make you pause: ~25 users per seat. This isn't a throughput problem, it's a correctness-under-contention problem: the entire design exists to serialize access to each seat so exactly one of those 25 wins, while the 100k/sec of browsing traffic never touches the source of truth.
High-Level Design#
Two things drive everything: serializing writes to each seat (correctness) and absorbing a half-million browsers (the read/queue problem). Keep them separate in your head.
Booking path (write): Requests pass a virtual queue at the gateway so the Booking Service is never stampeded. The Booking Service runs the hold → pay → confirm flow against the Seat DB, which is the single source of truth for seat ownership. Redis holds a TTL'd index of active holds for fast checks.
Browse path (read): The seat map is served from a Redis cache refreshed every ~2 seconds, 100k+ reads/sec that must never touch the source-of-truth DB.
The key insight: strong consistency is required only for the seat-assignment transaction, not for anything the user merely looks at. The booking write is CP and serialized per seat; the seat map is deliberately a little stale, and that's fine: a user who tries to grab a just-taken seat simply gets "seat unavailable, pick another."
Deep Dives#
1. The Three-Step Booking Flow#
Never go straight from "user clicked a seat" to "charge the card." You need a reservation in between, or two users can both reach payment for the same seat. The flow is browse → hold → confirm.
Phase 1: Hold (must be atomic on the seat)
The hold is what lets a user safely enter payment while the seat is guaranteed theirs.
Phase 2: Confirm (payment, then commit)
The re-check in 2a is essential: it closes the rare race where the hold expired (and the seat was released) while the payment call was in flight. Never confirm a seat you no longer hold.
2. Locking Strategy: Pessimistic vs Optimistic#
Two users hit "hold S1" at the same millisecond. Exactly one must win. This is the technical core of the interview.
Pessimistic: SELECT ... FOR UPDATE. Take an exclusive row lock, then decide.
Concurrent holds on S1 are serialized: the second waits for the first to commit, then sees held and loses.
Optimistic: compare-and-swap on a version (or on status itself).
No lock held across a round trip; the loser simply retries or gets a 409.
Decision: Pessimistic for the hold. A seat is a scarce, hot resource. With ~25 contenders, optimistic CAS means 24 immediate retries per seat, wasting work and adding latency. Pessimistic locking serializes them with a critical section of a few milliseconds, which is cheaper under this contention.
For senior interviews ↓ you may not need the lock, and how to avoid deadlocks
A single conditional UPDATE is already atomic. UPDATE seats SET status='held' WHERE id='S1' AND status='available' is one atomic statement: it's effectively a CAS on status, and checking rows_affected == 1 tells you if you won. You don't strictly need SELECT FOR UPDATE for a single-row hold; the explicit lock earns its keep only when you must read a value and make a multi-statement decision before writing (e.g. "hold the best available seat in section A"), where you SELECT ... FOR UPDATE SKIP LOCKED to grab the first unlocked candidate without blocking.
SKIP LOCKED for "give me any available seat." For general-admission-style "assign me the best open seat," SELECT ... WHERE status='available' FOR UPDATE SKIP LOCKED LIMIT 1 lets concurrent requests each grab a different unlocked row instead of all piling onto the same one, turning a contention hotspot into parallel throughput.
Multi-seat orders → deadlocks. A user buying seats and another buying can deadlock if they lock in opposite order. Always acquire seat locks in a canonical order (e.g. sorted by seat id) so lock acquisition can't cycle.
Don't hold a DB lock across the payment call. The lock lives only for the millisecond-scale hold transaction; the 10-minute reservation is represented by status='held' + hold_expires_at, not by an open transaction. Holding a row lock for 10 minutes while a human types their card number would be catastrophic.
3. Hold Expiry#
A user holds S1 and wanders off. After 10 minutes the seat must return to inventory, reliably, even if a process crashed. Use two mechanisms, because each covers the other's failure:
Redis TTL: SET hold:{S1} {user} EX 600. Fast, cheap, self-expiring; the natural "is this hold still alive?" check.
Database sweeper: a job every ~30s runs UPDATE seats SET status='available', hold_by=NULL WHERE status='held' AND hold_expires_at < NOW(). This is the authoritative release and the backstop for when Redis and the DB drift (a Redis eviction, a crash between the two writes, a network partition).
The DB is the source of truth; Redis is a fast index. If they disagree, the DB wins. A Redis key can vanish early (eviction) or linger, but hold_expires_at in Postgres is the real deadline, and the sweeper enforces it.
For senior interviews ↓ the expire-during-payment race and idempotent release
The nasty race: the sweeper releases S1 at the 10:00 mark, and a third user immediately holds and starts buying it, while the original user's payment (submitted at 9:58) is still in flight and now succeeds. Both could think they own S1.
The confirm transaction's re-check under lock (deep dive 1, step 2a) is what prevents it: the original user's confirm re-reads the seat inside the transaction, sees it's no longer held by them (the sweeper flipped it, the third user re-held it), and fails the confirm, at which point you refund/void the just-authorized payment. This is why you authorize during confirm and only capture after the seat write commits: a lost race becomes a voided authorization, not a charged customer with no seat. Release must also be idempotent: the sweeper and an explicit user-cancel can both try to free the same hold, and freeing an already-free seat must be a no-op.
4. Thundering Herd: Virtual Queue Admission Control#
At 10:00:00 sharp, 500k users hit one on-sale. If they all reach the Booking Service at once, it falls over before a single seat is sold. The fix isn't rate limiting (which silently drops users). It's a virtual waiting room.
Admission rate: if the Booking Service safely handles ~1,000 req/sec, admit at ~800/sec to leave headroom. Users see a live ETA, which converts anxious refreshing into an orderly, fair funnel. This is exactly how Ticketmaster and Queue-it work: it turns an uncontrolled stampede into a throttled stream the booking core can actually serve.
For senior interviews ↓ fairness, bots, and why the queue must be durable
Bots game FIFO. A pure first-come queue rewards scripts that open thousands of connections. Gate entry with verified phone/email or a CAPTCHA, fingerprint requests to collapse many entries from one actor, and rate-limit queue joins per identity. The queue is fair only if each human is one entrant.
The queue itself must survive load and failure. It's the shield, so it can't be the thing that melts. Back it with something horizontally scalable and durable (Redis sorted set by entry timestamp, or Kafka for strict ordering) so a gateway node dying doesn't drop everyone's place in line.
Admission ≠ purchase. Being admitted only grants a chance: inventory may sell out while you're in line. Communicate that honestly ("seats may sell out before you reach the front") rather than admitting people to a guaranteed-empty room, which just moves the disappointment later.
5. Seat-Map Read Path#
500k users staring at the seat map, availability changing every second as seats are held and released. This is ~100k+ reads/sec, and it cannot hit the source-of-truth Seat DB.
- Cache the whole event's seat map in Redis:
event:{id}:seatmap → compact blob - Refresh it from the DB every ~2 seconds (or push deltas on hold/book/release)
- Serve all browse traffic from cache; never query Postgres per map view
- Accept ~2s staleness: a user may see a seat as free, try to hold it, and get a
409, and the UI just says "just taken, pick another"
The strict-consistency requirement applies to the booking transaction, not the display. Conflating the two, trying to show a perfectly live map to 500k people, is what needlessly couples your read scale to your write correctness.
Data Model#
The bookings.seat_id UNIQUE constraint is the last line of defense: even if every layer of application logic somehow failed, the database physically refuses to record two bookings for one seat. Correctness constraints belong in the schema, not only in code.
API Design#
Note: identity (user_id) comes from the auth token, never a client field. During an on-sale, /hold also requires a valid admission token issued by the virtual queue; without it, the booking core is exposed to the full stampede.
Failure Scenarios and Edge Cases#
For senior interviews ↓ what breaks and how the system recovers
Payment succeeds but the confirm write fails
You've charged the card and then the DB commit fails, or the seat was lost to the expiry race. Fix the ordering: authorize the payment during confirm, write the seat in the transaction, and only capture after the commit succeeds. If the seat write fails, void the authorization; the customer is never charged for a seat they didn't get. Make confirm idempotent on hold_id so a client retry can't double-book or double-charge.
Hold expires while payment is in flight
The sweeper releases the seat at 10:00; the user's in-flight payment returns success at 10:01. The confirm's re-check-under-lock sees the seat is no longer theirs and fails; the authorization is voided. Covered by deep dive 3.
Redis (hold index or seat-map cache) goes down
Holds still work: the DB is the source of truth, and the atomic conditional UPDATE plus hold_expires_at don't depend on Redis; you lose only the fast hold index and fall back to DB checks. The seat map degrades to a slower/staler view or a brief "refresh" state, but you never oversell, because Redis was never authoritative for ownership.
Seat DB primary fails
Booking (writes) pauses until a replica is promoted, which is acceptable under CP: better to stop selling for seconds than to oversell. Browsing continues from cache and read replicas. This is the deliberate availability sacrifice the requirements allow.
Queue gaming / bots
Scripts flood the virtual queue to jump the line. Gate entry with verified identity/CAPTCHA, fingerprint to collapse duplicate entries, and rate-limit joins per identity. Covered in deep dive 4.
Double-submit / user hits confirm twice
Idempotency key on hold_id (or a client-supplied request id): the second confirm returns the same booking rather than attempting a second charge or write.
Confirmation storm
A sell-out generates 20k emails/PDFs in minutes. Enqueue them (SQS/Kafka) and process at a sustainable rate; ticket delivery is never on the booking critical path.
Code#
Hold (atomic claim) and Confirm (re-check + commit)#
Trade-off Summary#
| Decision | Chosen | Alternative | Why |
|---|---|---|---|
| Consistency model | CP (strong) for booking | AP | Overselling is never acceptable; correctness beats availability here |
| Locking | Pessimistic / atomic conditional UPDATE | Optimistic CAS with retries | ~25 contenders per seat make retry storms expensive; serialize instead |
| Reservation | Hold with TTL, then confirm | Selection straight to payment | Guarantees the seat is the user's while they pay; no double-payment races |
| Hold expiry | Redis TTL + DB sweeper | Redis TTL only | DB is source of truth; sweeper backstops Redis drift/eviction |
| Payment | Authorize on confirm, capture after commit | Charge, then write seat | A lost seat becomes a voided auth, never a charge without a seat |
| Demand spikes | Virtual queue | Rate limiting | Fair, visible, and shields the booking core; rate limiting silently drops users |
| Seat-map reads | Cached, ~2s stale | Strong-consistent live map | 100k+ reads/sec can't hit the source of truth; staleness is harmless for browsing |
Follow-up Questions#
Basic
Q: Why have a separate hold step? Why not select-then-pay?
Without a hold, two users can both reach the payment screen for the same seat and both get charged, or the second is rejected after entering card details, terrible UX and a refund mess. The hold reserves the seat the instant it's selected, so payment happens against a seat that's already guaranteed to be theirs for 10 minutes.
Q: Why pessimistic locking here when most systems prefer optimistic?
Optimistic locking shines under low contention, where retries are rare. Here ~25 users fight for each seat, so optimistic CAS produces 24 wasted retries per seat and a retry storm. Pessimistic locking (or a single atomic conditional UPDATE) serializes contenders with a millisecond critical section, cheaper than the churn.
Q: Why is a virtual queue better than rate limiting?
Rate limiting silently rejects requests: users just see errors and hammer refresh, amplifying load. A virtual queue admits everyone eventually, shows a live position/ETA, and throttles the stream reaching the booking core to a rate it can serve. It converts a stampede into a fair, orderly funnel.
Q: How can you serve a live-ish seat map to 500k people?
You don't serve a perfectly live one; you serve a ~2s-stale cached map from Redis, refreshed from the DB. Browsing is high-volume and tolerant of slight staleness; the booking transaction is where strong consistency is enforced. A user who grabs a just-taken seat simply gets a 409.
Senior follow-ups ↓
Q: A user's card is charged but they didn't get the seat. Walk me through how your design prevents that.
Payment is two-phase: authorize during confirm (funds held, not captured), write the seat inside the transaction with a re-check under lock, and capture only after the commit succeeds. If the seat write fails (the hold expired, the sweeper released it, another user re-held it) you void the authorization. The customer never sees a charge without a seat. Confirm is idempotent on hold_id, so a retry can't double-charge either.
Q: How do you handle an order for multiple seats atomically, and avoid deadlocks?
Lock all requested seats in a canonical order (sorted by seat id) so two overlapping orders can't acquire in opposite order and cycle. Do the whole multi-seat claim in one transaction: if any seat isn't available, the transaction rolls back and none are held: all-or-nothing, so a user never ends up holding half an order.
Q: Your Postgres primary can't take the write load for a mega-event. What do you do?
Seats per event are bounded (~100k), and contention is within an event, so shard by event_id: each hot event lives on its own primary and can be scaled vertically or isolated onto dedicated hardware for the on-sale. The virtual queue further caps the write rate reaching any one event's DB. Read load is already off the primary via the seat-map cache and replicas.
Q: How would you make the seat map feel live instead of 2s stale, without coupling it to booking correctness?
Push deltas instead of polling a cached blob: when a seat flips to held/booked/available, publish a small event that fans out to connected clients over WebSocket (the same presence/pub-sub pattern as a chat system). Clients patch their local map in near-real-time. The map is still just a view (booking correctness stays in the CP transaction) but the user sees seats gray out live, which reduces 409s at hold time.
Minute-by-Minute Interview Playbook#
0 to 5 min: Requirements Ask the questions above. Pin down reserved-vs-GA, the hold, the no-overselling hard constraint, and Taylor-Swift-scale demand. Confirm out-of-scope. Write FR/NFR, and write "CP, no overselling" prominently.
5 to 8 min: Capacity estimates Do the math out loud. The ~25-users-per-seat number reframes this as correctness-under-contention, and the 100:1 read:write skew justifies caching the seat map. State them before designing.
8 to 18 min: HLD Draw browse → hold → confirm. Show the Booking Service, Seat DB (source of truth), Hold Store, and the seat-map cache. Explain why the hold exists before the interviewer asks.
18 to 38 min: Deep dives
Lead with locking: draw two users grabbing S1, show exactly where the race is, and how the atomic conditional UPDATE / SELECT FOR UPDATE serializes them. This is the core technical moment. Then the virtual queue for the herd, then dual-mechanism hold expiry (and the expire-during-payment race), then the cached seat map.
38 to 45 min: Failure scenarios and wrap-up Cover authorize-then-capture (no charge without a seat) and the DB-primary-fails CP choice. Summarize in one sentence: "Serialize each seat with an atomic claim under CP, reserve with a TTL'd hold, shield the core with a virtual queue, and serve the map from cache: correctness where it matters, staleness where it doesn't."
Green flags
- Identifies the double-booking race immediately and puts the constraint in the schema
- Proposes the hold step before being prompted
- Knows
SELECT FOR UPDATE/ atomic conditional UPDATE and when pessimistic beats optimistic - Reaches for a virtual queue, not rate limiting, for the spike
- Separates strong consistency (booking) from eventual consistency (seat map)
- Orders payment as authorize→capture so a lost seat is never a charge
Red flags
- Application-level "check then set" with no DB-level locking or unique constraint
- No hold step; selection straight to payment
- Treats the demand spike as pure rate limiting
- Ignores hold expiry, or uses Redis TTL alone with no DB backstop
- Uses eventual consistency for the booking transaction
- Charges the card before the seat write commits
Further Reading#
Try a different category