Skip to content

00. Design a Ticketing System

~20 min read · Level: advanced · Part 1 of 4 (Overview → HLD → LLD → Q&A)

Problem

A ticketing system sells reserved seats for events — a concert, a cricket match, a movie show — where each seat is a distinct, one-time inventory unit that must be sold to exactly one buyer. This is the product behind BookMyShow, Ticketmaster, and the seat-selection screen on any airline. A fan picks seats on a map, the system holds them while payment goes through, and on success the seats become a confirmed booking with tickets. The core job sounds like ordinary inventory management until you notice two things that ordinary inventory does not have: the same physical seat cannot be sold twice under any circumstances, and demand does not arrive smoothly — it arrives as a wall.

What makes this a hard system-design question is the shape of that wall. For a hot event, nearly all the demand shows up in the first few seconds of the on-sale, all of it competing for the same small pool of seats, and the good seats draw a wildly disproportionate share of that competition. The system is not straining to serve a lot of distinct work; it is straining because thousands of requests want the same row at the same instant, and the one guarantee you cannot break is that only one of them gets it. Reads are almost incidental here. The battle is on the contended write.

To keep the reasoning concrete, thread one scenario through the whole design: a 10,000-seat concert goes on sale at 10:00:00am, and 100,000 fans hit "buy" in the first ten seconds. That is a ten-to-one ratio of demand to supply, arriving as a burst of roughly 10,000 join-requests per second, all funneling toward the same seat map, with the front-row seats drawing thousands of simultaneous grabs each. By the time the dust settles, exactly 10,000 seats must be sold — no seat twice — and the 90,000 fans who could never have gotten a seat must receive a clean, fast "sold out" rather than a spinning page or, worse, a confirmation for a seat that someone else also holds. That scenario, and the tension between never overselling and still moving fast, tests every decision below.

Functional requirements

  • Browse and hold: show a live seat map for an event and let a fan select and hold specific seats for a bounded window while they pay.
  • Reserve with a TTL: a hold reserves seats for a fixed time (say 8 minutes); if payment does not complete in that window, the seats are released back to the pool.
  • Checkout: convert a valid hold into a confirmed, paid booking, and issue tickets.
  • No double-sell: a seat is sold to at most one buyer, ever — the hard invariant.
  • Virtual waiting room: admit fans into the purchase flow at a controlled rate during a high-demand on-sale, in roughly fair (arrival-ordered) fashion.
  • Sold-out state: once inventory is exhausted, latecomers get an immediate, unambiguous sold-out response.

De-scoped for this round, and worth naming so the interviewer hears it as a choice rather than an omission: dynamic/surge pricing, seat recommendations and best-available auto-pick, secondary-market resale, refunds and partial cancellations, and fraud/bot detection beyond basic rate-limiting. These are real and some are lucrative, but they sit beside the core reservation engine and do not change its shape.

Non-functional requirements

The dominant constraint is correctness under write contention — never selling a seat twice, even when thousands of requests contend for it in the same millisecond. Everything else bends to that.

  • Consistency: seat state must be strongly consistent on the write path. A hold either wins a seat outright or it does not; there is no "eventually one of you loses." This is the opposite of the URL shortener, where eventual consistency on reads was fine.
  • Fairness: during an on-sale, admission should be roughly first-come-first-served, and a fan who has waited should not be leapfrogged by a bot storm. Fairness is a product requirement here, not a nicety.
  • Availability of browsing, integrity of buying: showing the seat map can degrade gracefully (a slightly stale map is tolerable); committing a purchase cannot. We trade availability for correctness precisely at the commit.
  • Latency: a hold attempt should resolve in well under a second so the fan gets a fast yes/no; the seat map should feel live. But latency is subordinate to correctness — a correct "no" beats a fast "maybe."
  • No overselling under partial failure: crashes, timeouts, and double-clicks must never leave two valid claims on one seat. Idempotency and self-expiring holds are load-bearing, not optional.

Scale estimation

Take the scenario at face value: 100,000 fans, 10,000 seats, arriving in the first ten seconds of the on-sale.

The join burst is the loudest number. If 100,000 fans hit the endpoint over ten seconds, that is 100,000 / 10 = 10,000 join-requests/second at peak. This is what the virtual waiting room absorbs — and note that it is a burst against a queue, not against the seat engine. The whole point of the queue is that these 10,000/s never reach the contended inventory directly.

Behind the queue, the seat engine sees a far smaller, admission-controlled stream. There are only 10,000 seats, so at most 10,000 seats can ever be held, and even with churn — holds that expire or fail and get re-held — realistic total hold attempts land around 20,000–30,000 over the life of the sale. If the interesting part of the sale lasts about four minutes (~240 s), that averages ~100 hold-attempts/second, bursting to perhaps 2,000/s in the opening seconds when the first admitted wave all grabs at once. That burst, not the average, is what the inventory store must survive. The sharpest sub-case is a single hot seat: front-row A1 might draw several thousand hold attempts in one second, of which exactly one may win — a per-row contention problem, not an aggregate-throughput one.

Reads are cheap by comparison. If we admit up to ~15,000 concurrent active shoppers (1.5× the seat count — more is wasted, since there are only 10,000 seats to sell) and each needs a live seat map, a naive poll every 3 seconds is 15,000 / 3 = 5,000 reads/second. But we do not poll; we push. Only ~10,000 seats change state over the whole sale, so the push fan-out is on the order of 40 state-deltas/second broadcast to admitted shoppers — trivial compared to the reads it replaces.

Storage per event is tiny: 10,000 seat rows at ~150 bytes each is ~1.5 MB of inventory, plus ~10,000 bookings at ~300 bytes is ~3 MB. A single event's entire state fits in memory many times over. The platform-wide scale comes from running thousands of such events, not from any one event being large — which tells you the partition key immediately: shard by event, keep one event's hot inventory together, and each on-sale becomes an isolated contention problem rather than a shared one.

API sketch

POST /api/v1/events/{event_id}/queue           # join the virtual waiting room
  200: { "queue_token": "q_...", "position": 41204, "eta_seconds": 180 }

GET  /api/v1/events/{event_id}/queue/{token}    # poll admission status
  200: { "status": "waiting", "position": 8210 }
       | { "status": "admitted", "session_token": "s_...", "expires_at": "10:12:00Z" }

GET  /api/v1/events/{event_id}/seats            # live seat map (admitted session only)
  200: { "seats": [ { "id": "A1", "state": "available|held|sold", "price_cents": 750000 }, ... ] }

POST /api/v1/events/{event_id}/holds            # hold specific seats
  body: { "seat_ids": ["A1","A2"], "session_token": "s_..." }
  201:  { "hold_id": "h_...", "seat_ids": ["A1","A2"], "expires_at": "10:08:00Z" }   # 8-min TTL
  409:  { "conflict_seats": ["A1"] }            # someone else already won A1

POST /api/v1/holds/{hold_id}/checkout           # convert hold to paid booking
  body: { "payment_token": "pt_...", "idempotency_key": "uuid" }
  201:  { "booking_id": "b_...", "tickets": [ ... ] }
  410:  hold expired — seats already released

DELETE /api/v1/holds/{hold_id}                  # release seats early (back button)
  204

Solutioning

Start from the invariant — a seat is sold at most once — and the architecture organizes itself around protecting it under a burst. The naive design puts every buyer straight onto the seat map and lets them race; with 100,000 requests hitting 10,000 seats in ten seconds, that race melts the inventory store, and worse, a sloppy check-then-write lets two winners claim A1. The reframing that unlocks the design: a hot on-sale is not a traffic-scaling problem, it is a contention problem — 100,000 requests fighting over 10,000 rows, not 100,000 requests fighting over bandwidth. You do not solve it by adding read replicas; you solve it by controlling who gets to write and by making each write atomically decide a single seat's owner.

That gives the first structural decision: a virtual waiting room in front of the seat engine. Instead of letting the 10,000/s join burst reach inventory, the queue accepts everyone instantly (a cheap append), assigns an arrival-ordered position, and admits fans into the active shopping pool at a rate the engine can actually handle — capping concurrent active shoppers at roughly 1.5× the seat count, since admitting more just manufactures losers who contend for seats that no longer exist. The queue turns a stampede into a throttled, roughly-fair stream and gives the fan honest feedback ("you are number 41,204") instead of a frozen page. Fairness here is bought with a queue, not with hope.

The second decision is how a hold acquires a seat, and this is the real tradeoff of the problem: pessimistic versus optimistic locking. Pessimistic locking — SELECT ... FOR UPDATE on the seat row, or a distributed lock held for the whole shopping session — is simple to reason about but ruinous here: a hold lasts up to 8 minutes, so a row lock held that long throttles the seat to one attempt every 8 minutes, and a fan who closes their laptop mid-purchase holds the lock until something reaps it. Optimistic concurrency wins: a hold is acquired by a single atomic compare-and-swap on the seat's state (available → held guarded by a version), so thousands can attempt A1 in parallel, exactly one CAS succeeds, and the rest get an instant 409 telling them to pick another seat. Distinct seats never contend with each other at all; only the same seat serializes, and only for the microseconds of one CAS. The memory hook: a hold is not a lock, it is a lease — a short, self-expiring claim, not a mutex a client can hold open forever.

That lease framing resolves the third tension, overselling prevention versus throughput, with concrete numbers. A held seat is not sold; it is reserved with an 8-minute TTL. If payment succeeds, the hold promotes to a sold booking; if it lapses, the seat auto-releases and returns to the pool. This is what lets throughput stay high without ever risking a double-sell: the CAS guarantees at most one holder, and the TTL guarantees a crashed or abandoned checkout cannot strand a seat forever. Put the scenario on it: with a 70% payment-conversion rate, roughly 3,000 of the first 10,000 holds lapse and their seats re-enter the pool for the next admitted wave — which is exactly why total hold attempts (~25,000) exceed seat count while sold seats never exceed 10,000. When the 10,000th seat is confirmed sold, the inventory counter hits zero, and every fan still in the queue is admitted straight to a sold-out response rather than a dead seat map. Fan number 40,000 in line never sees a seat they cannot have; they see the truth, fast.

The result is a system with three rails: a queue rail that admits fans fairly and applies backpressure, a reservation rail whose every seat claim is a single atomic CAS backed by a self-expiring lease, and a payment rail that promotes a lease to a booking idempotently. The following files take each rail down to components (HLD) and then to schemas, the CAS-and-lease algorithm, and the concurrency corners where double-sells actually sneak in (LLD).