Source: https://www.hack2hire.com/question-bank/questions/system-design/69d1f51c17b72775cc49c4d0
Company: Meta, Microsoft
Difficulty: Hard
Problem statement
Sell seats for concerts and games where demand spikes the moment sales open: hold seats briefly, never sell one twice, and stay up under the rush.
Requirements
We are building the booking core of a Ticketmaster-style site: fans find an event, pick seats on a seat map, hold them for a few minutes while they pay, and get tickets, with each seat sold at most once even when a stadium onsale draws millions of people in the first minute.
Clarify early: reserved seats or general admission (both, per section)? Per-account ticket limits? Does a waiting room already exist, or is it in scope? Is resale in scope (no, out of scope here)?
Functional requirements
- Search and browse events by name, artist, venue, city, date; open an event page with sections, prices and a seat map.
- Select up to N seats (default 8) and place a hold that expires after a fixed window (e.g. 8 minutes).
- Pay and confirm: a held seat becomes sold, an order and tickets are issued, a confirmation is emailed.
- Release holds on expiry, cancel, or payment failure, and return seats to the pool.
- Show near-live availability while people shop; let a client recover the state of its hold or order after a crash or timeout.
- Admin: publish events, set price tiers, set the onsale time and per-account limits.
Non-functional requirements
- No double sale, ever. Strong consistency on seat state; everything else may be eventually consistent.
- Survive onsale spikes of 100× normal traffic on a single event without hurting other events.
- Hold placement p99 under 300 ms once a user is admitted; search p99 under 200 ms.
- Fair access: first-come order roughly preserved, bots throttled.
- 99.99% availability for the hold and confirm path during an onsale; a lost hold is worse than a slow one.
Back-of-the-envelope estimation
- 100k events live, ~50M seats on sale at any time; 20M orders/year (~1 order/s average).
- A big onsale: 2M people arrive for a 60k-seat stadium within 5 minutes. Seat-map reads: 2M users × 1 refresh/5 s ≈ 400k reads/s against one event.
- Hold writes if admitted freely: tens of thousands/s on one event; with a waiting room admitting 2,000 users/s and ~1.5 hold attempts each, ≈ 3k hold writes/s on that event's partition.
- Seat row ≈ 100 B → 60k seats ≈ 6 MB per event; the whole live inventory ≈ 5 GB. Small; the problem is contention, not size.
- Sold out in ~10 minutes: 60k seats / 600 s ≈ 100 confirmations/s, each with a payment call of 1–3 s.
API design
All writes take an Idempotency-Key header; replays return the original result.
GET /v1/events?q=taylor&city=SEA&from=2026-11-01 -> {events:[{event_id, name, venue, starts_at, min_price, status}], next_cursor}
GET /v1/events/{event_id} -> {event, sections:[{section_id, price_tier, available_count}], onsale_at}
GET /v1/events/{event_id}/seatmap?section=112 -> {version, seats:[{seat_id, row, num, state: "open"|"taken"}]} (projection, may lag)
POST /v1/events/{event_id}/holds {seat_ids:[...], admission_token}
-> 201 {hold_id, seat_ids, expires_at, total}
-> 409 {code:"seat_unavailable", taken:[seat_id...], seatmap_version}
-> 429 {code:"not_admitted", queue_url}
DELETE /v1/holds/{hold_id} -> 204
POST /v1/holds/{hold_id}/confirm {payment_method_token}
-> 202 {order_id, status:"pending_payment"}
GET /v1/orders/{order_id} -> {status:"pending_payment"|"confirmed"|"failed", tickets:[...]}
GET /v1/me/holds/active -> {hold_id, expires_at} | 404 (recovery after a crash)
WS /v1/events/{event_id}/availability -> pushes {section_id, available_count, changed_seats, version}
The seat map is a cache; the hold call is the authoritative check. A 409 lists exactly which seats lost, so the client can redraw without a full reload.
Data model
Inventory store: sharded relational DB (Postgres/MySQL, or Spanner/CockroachDB), partitioned by event_id. One event's seats live in one shard, so a hold for several seats is a single-shard transaction.
| Table | Key columns | Notes |
|---|---|---|
events |
event_id PK, venue_id, starts_at, onsale_at, status, max_per_account |
Catalog source of truth |
seats |
PK (event_id, seat_id), section_id, row, num, price_tier, state (open/held/sold), hold_id, hold_expires_at, version
|
The authoritative inventory row |
holds |
hold_id PK, event_id, user_id, seat_ids (json), expires_at, status (active/confirming/converted/released) |
Index (user_id, status); index (expires_at) where active |
orders |
order_id PK, hold_id UNIQUE, user_id, amount, payment_intent_id, status |
hold_id unique stops double charge for one hold |
tickets |
ticket_id PK, order_id, event_id, seat_id; UNIQUE (event_id, seat_id) |
Second line of defence against double sale |
outbox |
id, aggregate_id, type, payload, published_at | Seat and order changes, relayed to Kafka |
Other stores:
-
Search index (Elasticsearch/OpenSearch): events denormalized with venue, performers, min price,
on_saleflag. Fed from the outbox; seconds of lag is fine. -
Availability projection (Redis): per event
avail:{event_id}:{section}counts and a bitmap of open seats, plus aversion. Serves the 400k reads/s. - Waiting room (Redis + edge): per-event queue position counters and signed admission tokens.
Why not one document per event holding all seats: every hold rewrites the same 6 MB document, so all writers serialize on one row lock or one CAS loop and retry storms grow with traffic. Seat-level rows let disjoint seats commit in parallel and keep row locks narrow.
High-level design
flowchart LR
U[Client] --> CDN[CDN and edge]
CDN --> WR[Waiting room]
CDN --> GW[API gateway]
GW --> SS[Search service]
SS --> ES[(Search index)]
GW --> AV[Availability service]
AV --> RC[(Redis projection)]
GW --> BK[Booking service]
BK --> DB[(Inventory DB sharded by event)]
BK --> PAY[Payment service]
PAY --> PSP[Payment provider]
DB --> OB[Outbox relay]
OB --> K[Kafka]
K --> RC
K --> ES
K --> NT[Notifications]
EX[Hold expiry worker] --> DB
-
Search and open an event. Search hits the index; the event page is cached at the CDN. The seat map comes from the Redis projection with a
version, and the client opens a WebSocket for deltas. -
Onsale admission. At
onsale_at, the edge routes the event's traffic to the waiting room. Users get a random position, then a signed admission token (event_id, user_id, expiry 10 min) when the drain rate reaches them. The gateway rejects hold calls without a valid token. -
Place a hold. The booking service runs one transaction on the event's shard: conditional update of the requested seats from
opentoheld, insert the hold, insert outbox rows. If fewer rows changed than requested, roll back and return 409 with the losers. -
Confirm. The client posts a payment token. The service sets the hold to
confirming, extends its expiry by a grace period, creates an order withpending_payment, and calls the payment provider with the order_id as idempotency key. On success one transaction flips seats tosold, inserts tickets and marks the orderconfirmed. -
Publish. The outbox relay sends seat and order events to Kafka. Consumers update Redis counts and bitmaps, push WebSocket deltas, update
on_sale/sold-out in search, and send emails. -
Expiry and recovery. A worker releases holds past
expires_at. A client that lost its connection callsGET /me/holds/activeorGET /orders/{id}and resumes from the server's state.
Deep dives
Oversell prevention and holds. The only write that can take a seat is a conditional update on the authoritative row:
UPDATE seats SET state='held', hold_id=:h, hold_expires_at=now()+interval '8 min', version=version+1
WHERE event_id=:e AND seat_id IN (:ids)
AND (state='open' OR (state='held' AND hold_expires_at < now()));
Compare affected rows with the number requested; on a mismatch, roll back so the user gets all or none of the seats. Expired holds can be reclaimed inline, so the system never depends on the expiry worker being on time. The UNIQUE (event_id, seat_id) on tickets catches any bug that slips past. The seat map the client shows never decides anything: it is a hint for drawing, and the conditional update is the vote.
| Approach | Correct under races | Throughput on one event | Notes |
|---|---|---|---|
| Read then write, no lock | No | High | Double sales |
SELECT ... FOR UPDATE on seats |
Yes | Medium; lock waits pile up | Fine at low contention |
| Conditional update / optimistic version | Yes | High; losers fail fast | Pick this |
Redis SETNX per seat as the lock |
Yes if Redis is durable | Very high | Two sources of truth; needs DB reconciliation |
| Single-writer queue per event | Yes | ~10k ops/s serial | Good for extreme events, adds latency |
Flash-sale admission and hot-event isolation. The DB shard for one event handles a few thousand transactions/s, not 2M users. The waiting room converts the spike into a steady flow: admit at a rate set from measured hold latency (start at 2,000 users/s, back off when p99 passes 300 ms). It is served from the edge (static page plus a Redis counter), so it can take millions of connections cheaply. Hot events get their own shard and their own booking-service pool (bulkhead), so a stadium onsale cannot starve 99,999 small events. Per-account and per-card limits plus CAPTCHA at queue entry handle bots; the token binds to user_id so it cannot be resold.
Hold expiry and payment ambiguity. Holds expire 8 minutes after creation. The expiry worker scans holds by expires_at every few seconds and releases in batches; inline reclaim covers its lag. The hard case is a payment that is still running when the hold times out. Rules: confirm moves the hold to confirming and extends expiry by 5 minutes; the worker skips confirming holds until the grace ends, then asks the payment provider for the intent's status. Paid means finish the sale; failed or unknown after retries means release and void. If the seats were somehow released and resold while a charge succeeded (should be impossible with the grace rule), refund automatically and alert. Use the order_id as the payment idempotency key so a retried confirm never charges twice.
Projection freshness vs authoritative reads. The Redis projection lags the DB by the outbox relay plus Kafka, usually 200–800 ms, and seconds when the relay backs up during an onsale.
| Strategy | Freshness | Load on inventory DB | When to use |
|---|---|---|---|
| Poll seat map every N s | N s + lag | None | Normal events |
| WebSocket/SSE deltas from Kafka | Sub-second | None | Default during onsales |
| Read inventory DB on every map view | Exact | 400k reads/s, unsafe | Never during onsales |
The client treats the map as advisory and relies on the 409 to learn the truth, then applies the taken list at once. Each projection update carries the DB version; consumers drop out-of-order updates. If a client's socket falls behind by more than N versions, it refetches a full snapshot. At high contention, show section-level counts and "best available" instead of a live seat-by-seat map, which removes most of the stale-click failures.
Giant venues and general admission. For a 90k-seat festival with GA floors, seat-level rows are unnecessary. Model GA as a counter per section: UPDATE sections SET held=held+:n WHERE id=:s AND sold+held+:n <= capacity. "Best available" for reserved seating runs server-side over a per-section free list, so users ask for "2 seats in 112" instead of racing for the same visible seat.
Trade-offs
- Strong consistency on one shard per event makes double sale impossible, but caps one event's write rate at what one shard can do; the waiting room is what makes that cap acceptable.
- The waiting room gives fairness and protects the core, but adds minutes of wait and its own failure mode (if it drops its state, everyone loses their place, so keep positions in replicated Redis).
- An 8-minute hold balances checkout success against seats locked by people who leave; shorter holds release faster but fail slow payers.
- The Redis projection serves huge read load cheaply, but users will sometimes click seats that are already gone; we accept that and make the 409 fast and specific.
- The outbox adds a second write per transaction and some lag, but avoids dual-write bugs between the DB and Kafka.
- Disaster recovery: synchronous replication within a region (RPO 0 for inventory) costs commit latency; cross-region async replicas give region failover with a short pause in sales instead of risking split-brain double sales. For a once-in-a-lifetime onsale, freeze deploys, pre-scale, pre-warm caches, and keep a "pause sales" switch.
Interviewer expectations
Mid-level (L4)
- Clean entities (event, seat, hold, order) and a working API for search, hold and confirm.
- Prevents double sale with a transaction or lock, and holds that expire.
- Notices that seat-map reads are far more frequent than writes and caches them.
Senior (L5)
- Conditional update or optimistic versioning with all-or-nothing holds, and a unique constraint as a backstop.
- Separates the authoritative inventory from the cached seat map and explains the 409 path.
- Waiting room with an admission rate tied to backend capacity; idempotent confirm; payment-vs-expiry race handled with a grace state.
Staff (L6+)
- Hot-event isolation (dedicated shards and pools), numbers that justify admission rates, and a freshness strategy that switches during onsales.
- Recovery design: outbox, reconciliation with the payment provider, client recovery reads, refund on impossible states.
- Operational posture for a big onsale: pre-scaling, kill switches, DR choices that never risk two regions selling the same seat.