Engineering Notes
Home/System Design/System Design/Design a Ticket Booking System
System Design · System Design

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.

🏗️ System Design● Advanced⏱ 4 min read▶ 3 interactive🛠️ 2 exercises🎤 5 interview Q&As

🎯 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

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

text
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 early

4Holding Seats Without Overselling

The core is a seat state machine: available → held (with expiry) → sold, or held → available on expiry/cancel.

ApproachHowTrade-offs
Pessimistic lockingSELECT … FOR UPDATE on the seat rows, update to heldSimple and correct; locks queue up under heavy contention
Conditional updateUPDATE seats SET status='held', hold_id=?, expires_at=? WHERE id IN (...) AND (status='available' OR expires_at < now()) and check the affected row countCorrect, no long locks, naturally handles expired holds; all-or-nothing needs a transaction
Redis hold keysSET seat:{id} holdId NX PX 600000 per seat; DB updated on purchaseVery 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:

  1. Create the order in pending state linked to the hold.
  2. Call the payment provider with an idempotency key.
  3. On success: in one transaction mark seats sold, order paid, issue tickets. If the hold expired meanwhile but the seats are still unsold, extend; if they were sold to someone else, refund automatically.
  4. 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.
Users enter a virtual waiting room at the edge and are admitted at a controlled rate with an access token; admitted users read a cached seat map, place holds with conditional updates in the seat inventory database, then pay through the payment service with an idempotency key, and a sweeper releases expired holds
Users enter a virtual waiting room at the edge and are admitted at a controlled rate with an access token; admitted users read a cached seat map, place holds with conditional updates in the seat inventory database, then pay through the payment service with an idempotency key, and a sweeper releases expired holds

⚠️ Common Pitfalls

  1. Check-then-update (SELECT available then UPDATE) without a condition or lock: two buyers get the same seat.
  2. Holding seats only in application memory: lost on restart, inconsistent across instances.
  3. No hold expiry: abandoned carts block inventory forever.
  4. Calling the payment provider inside a DB transaction holding seat locks.
  5. No waiting room: on-sales become a DDoS on your own database.

📋 Quick Reference

text
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

Questions interviewers ask about this topic. Answer out loud first, then open a card to compare.

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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.

0–5 min
Requirements
  • Seat map, hold seats for minutes, pay, issue tickets, handle hot on-sales fairly
  • Never oversell; fairness; bot resistance
5–10 min
Estimation
  • 2 M fans for 60k seats in minutes: contention on a few hot rows, not total volume
10–15 min
API and data model
  • GET /events/{id}/seats, POST /holds, POST /holds/{id}/checkout
  • Seats table with status, hold_id, expires_at; orders
15–25 min
High-level design
  • Waiting room → booking API → cached seat map → conditional holds in seat inventory → payment → commit
Interviewer pushes on
  • How does a hold work? UPDATE … WHERE status='available' OR expired, check affected rows.
25–40 min
Deep dives
  • Concurrency control options
  • Hold expiry and payment races
  • Virtual waiting room and bot defences
Interviewer pushes on
  • 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.
40–45 min
Wrap-up
  • 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.

1. Two buyers hold the same seat at once with UPDATE … WHERE status='available'. What happens?
The condition serializes them.
2. Why do holds need an expiry?
Release inventory.
3. Where should the payment call happen?
Don't hold locks during external calls.
4. What does a virtual waiting room protect?
Admission control.