Design event booking (Ticketmaster-style)
An event ticketing platform sells a fixed inventory of seats for concerts and sports—often 50,000 seats with 10 million fans refreshing at on-sale time. The core challenge is correctness under extreme contention: never sell the same seat twice, release holds when checkout abandons, and survive flash traffic without turning inventory into a race condition bug.
This guide covers requirements, capacity for on-sale spikes, seat holds, atomic inventory, payment handoff, virtual waiting rooms—and failure points and failure modes at the same depth as the other classic design articles on this site.
Design prompt
Design a ticket booking system where users browse events, select seats, hold them during checkout, and complete purchase.
On a hot on-sale, millions of concurrent users compete for limited inventory—you must prevent overselling and handle payment failures safely.
What you should be able to do after reading:
- Model seat states: available → held → sold with TTL on holds.
- Choose atomic inventory updates (DB conditional update vs Redis + sync).
- Design virtual waiting room / queue for flash sales.
- Integrate payment with idempotency and saga-style confirm/release.
- Map failure points and failure modes (double book, orphan holds, payment split-brain).
1. Requirements gathering
1.1 Functional requirements
- Browse events — search by city, date, artist; event detail page.
- Seat map — view sections, prices, availability (available / held / sold).
- Hold seats — reserve selected seats for checkout (e.g. 10 minutes).
- Checkout & pay — collect payment; confirm booking; issue tickets (QR/barcode).
- Release holds — on timeout, cancel, or payment failure.
- Order history — user views purchased tickets.
- Waitlist (optional) — when sold out.
Usually out of scope unless asked: secondary resale marketplace, dynamic pricing ML, venue operations, full fraud/scalper bot war (mention queue + rate limits).
1.2 Non-functional requirements
- Correctness — zero overselling; at-most-one owner per seat.
- Flash traffic — absorb 1M+ RPS on read paths; serialize writes per seat.
- Latency — hold operation < 500 ms p95 when admitted from queue.
- Availability — browse can degrade; inventory writes must be durable.
- Fairness — FIFO queue for on-sale; anti-bot (CAPTCHA, device fingerprint) optional.
- Idempotency — duplicate checkout retries must not double-charge or double-book.
Assumptions for capacity math: Mega on-sale: 50,000 seats; 5M users attempt purchase in first 30 minutes; 500k users admitted to shop concurrently via queue; hold TTL 10 minutes; avg 2 seats per order; payment 30 s after hold.
2. Capacity estimation
2.1 On-sale traffic spike
Users hitting site in 30 min = 5,000,000 Average RPS (all pages) = 5M / 1800 ≈ 2,800/sec Peak burst (first 60 sec) ≈ 50,000–100,000 RPS at edge (CDN + queue) Only ~500k concurrent "shoppers" inside queue → seat map + hold RPS bounded
2.2 Hold / inventory write rate
Successful orders goal ≤ 50,000 seats If avg checkout attempt selects 2 seats and 50% fail payment: Attempted holds might be 50k–100k in first hour Peak hold attempts/sec (admitted users) ≈ 2,000–5,000/sec worst case Each hold = 1–4 seat rows updated atomically
Writes are far lower than read RPS—but each write must be correct.
2.3 Seat map reads
500k concurrent shoppers refreshing map every 5 sec Read RPS ≈ 500,000 / 5 = 100,000/sec Serve from CDN + cached availability bitmap per section; not 50k row DB reads per page view
2.4 Storage
Seats per large venue ≈ 50,000 Bytes per seat row ≈ 100 B → 5 MB per event inventory table Orders per year (platform) = 100M × 2 seats × 200 B ≈ 40 GB/year orders (small metadata)
2.5 Infrastructure sizing (starting point)
| Component | Initial sizing |
|---|---|
| Edge + WAF + queue | Waiting room token; 100k+ RPS static/browse |
| Inventory service | Sharded by event_id; primary DB + Redis hot path |
| Hold store | Redis TTL keys + DB authoritative state |
| Booking service | State machine: held → paid → confirmed |
| Payment | External PSP; idempotency keys |
3. High-level design
- Waiting room — admits users at sustainable rate during on-sale.
- Catalog service — events, venues, metadata (read-heavy).
- Seat map service — layout + cached availability view.
- Inventory service — source of truth for seat state; atomic hold/release/sell.
- Reservation service — cart, hold TTL, ties user to seat IDs.
- Booking service — creates order; calls payment; confirms sale.
- Payment service — PSP integration (see payment system guide).
- Ticket service — generate ticket tokens after confirm.
flowchart TB
U[Users]
WR[Waiting room / queue]
GW[API Gateway]
CAT[Catalog]
MAP[Seat map cache]
INV[Inventory service]
RES[Reservation service]
BK[Booking service]
PAY[Payment PSP]
DB[(Inventory DB)]
R[(Redis holds)]
U --> WR
WR --> GW
GW --> CAT
GW --> MAP
GW --> RES
RES --> INV
INV --> R
INV --> DB
RES --> BK
BK --> PAY
BK --> INV
Seat state machine
AVAILABLE --hold(user, ttl)--> HELD --confirm(payment)--> SOLD
HELD --expire/cancel--> AVAILABLE
SOLD (terminal)
sequenceDiagram
participant U as User
participant R as Reservation
participant I as Inventory
participant B as Booking
participant P as Payment
U->>R: POST hold seats S1 S2
R->>I: atomic hold
I-->>R: hold_id expires 10m
R-->>U: checkout session
U->>B: POST checkout idempotency_key
B->>P: charge
P-->>B: succeeded
B->>I: confirm hold → SOLD
B-->>U: tickets issued
4. Database design
4.1 Core tables
CREATE TABLE events ( id UUID PRIMARY KEY, venue_id UUID NOT NULL, name TEXT NOT NULL, starts_at TIMESTAMPTZ NOT NULL, on_sale_at TIMESTAMPTZ NOT NULL, status TEXT NOT NULL DEFAULT 'scheduled' ); CREATE TABLE seats ( id UUID PRIMARY KEY, event_id UUID NOT NULL, section TEXT NOT NULL, row_label TEXT NOT NULL, seat_number INT NOT NULL, price_cents INT NOT NULL, status TEXT NOT NULL DEFAULT 'available', version BIGINT NOT NULL DEFAULT 0, held_by UUID, hold_expires_at TIMESTAMPTZ, order_id UUID, UNIQUE (event_id, section, row_label, seat_number) ); CREATE INDEX idx_seats_event_status ON seats (event_id, status); CREATE TABLE reservations ( id UUID PRIMARY KEY, user_id UUID NOT NULL, event_id UUID NOT NULL, status TEXT NOT NULL, -- active | expired | completed expires_at TIMESTAMPTZ NOT NULL, idempotency_key TEXT UNIQUE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE reservation_seats ( reservation_id UUID NOT NULL REFERENCES reservations(id), seat_id UUID NOT NULL REFERENCES seats(id), PRIMARY KEY (reservation_id, seat_id) ); CREATE TABLE orders ( id UUID PRIMARY KEY, reservation_id UUID NOT NULL UNIQUE, user_id UUID NOT NULL, payment_id TEXT, total_cents INT NOT NULL, status TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() );
4.2 Atomic hold (SQL pattern)
UPDATE seats
SET status = 'held',
held_by = :user_id,
hold_expires_at = NOW() + INTERVAL '10 minutes',
version = version + 1
WHERE id = :seat_id
AND event_id = :event_id
AND status = 'available'
RETURNING id;
-- If 0 rows updated → seat unavailable; try another seat
Multi-seat hold: single transaction updating all seat IDs; all-or-nothing rollback if any seat fails.
4.3 Redis acceleration (optional hot path)
SET seat:{event_id}:{seat_id} held:{user_id} NX EX 600
-- On success, async confirm write to PostgreSQL
-- On mismatch, reconciliation job fixes drift
Interview tip: prefer DB as source of truth unless you can explain reconciliation; Redis for read bitmap cache only is safer.
5. API design
5.1 Get seat map availability
GET /v1/events/{event_id}/seatmap
Returns section-level bitmap or compressed seat states (not 50k individual objects per call if possible).
{
"event_id": "evt_1",
"sections": [
{ "id": "A", "available_count": 120, "prices_from_cents": 7500 }
],
"updated_at": "2026-05-27T19:00:05Z"
}
5.2 Hold seats
POST /v1/reservations
{
"event_id": "evt_1",
"seat_ids": ["seat_101", "seat_102"],
"idempotency_key": "hold_user42_1"
}
201 Created
{
"reservation_id": "res_abc",
"expires_at": "2026-05-27T19:10:00Z",
"total_cents": 15000
}
409 Conflict — one or more seats unavailable.
5.3 Checkout (pay + confirm)
POST /v1/orders
{
"reservation_id": "res_abc",
"payment_method_id": "pm_xyz",
"idempotency_key": "order_user42_1"
}
201 Created — tickets issued; seats → sold.
5.4 Waiting room token
GET /v1/events/{event_id}/queue → 202 with position, or 200 with shop token when admitted.
6. Diving deep into key components
6.1 Virtual waiting room
- Before on-sale, users land on queue page; edge assigns queue position (Redis sorted set or dedicated queue vendor).
- Admit N users/minute sustainable for inventory service (e.g. 50k/min).
- Shop token (JWT) required on hold/checkout APIs; short TTL.
- Stops 5M users from hammering seat map DB simultaneously.
6.2 Preventing overselling
| Approach | Mechanism | Notes |
|---|---|---|
| Optimistic locking | version column on seat row | Retry on conflict |
| Conditional UPDATE | WHERE status='available' | Interview default |
| Pessimistic lock | SELECT FOR UPDATE | Serializable per seat; slower |
| Distributed lock | Redis Redlock per seat | Risky alone; pair with DB confirm |
Rule: exactly one layer must be authoritative for SOLD transition—usually the relational DB transaction.
6.3 Hold TTL and sweeper
- On hold, set
hold_expires_at = now + 10 min. - Background job (every 30 s) releases seats where
status=held AND hold_expires_at < now(). - Redis TTL key as hint; DB update is source of truth.
- User extending checkout: optional one-time +5 min if payment in progress.
6.4 Payment handoff (saga)
- Hold — inventory reserved.
- Charge — call PSP with idempotency key.
- Confirm — if payment OK,
UPDATE seats SET status='sold'in same TX as order insert. - Compensate — if payment fails, release hold; if payment OK but confirm crashes, reconciliation job completes or refunds.
See the payment system guide for idempotency and webhook failure modes.
6.5 Seat map caching
- Precompute availability bitmap per section; update on hold/sell/release events via Kafka.
- CDN caches static venue geometry; dynamic availability from edge cache with 1–2 s TTL during on-sale.
- Client may show “stale” map for 1 s; server rejects hold on unavailable seat.
6.6 General admission (no assigned seats)
Sell capacity counter instead of per-seat rows:
UPDATE event_inventory SET sold = sold + :qty WHERE event_id = :id AND sold + :qty <= capacity
Atomic decrement of remaining; simpler but no seat map UI.
6.7 Idempotency
idempotency_keyon hold and order — return same reservation/order on retry.- PSP charge id tied to order id — no double charge on client retry.
7. Failure points
Failure points are where faults cause overselling, orphan holds, lost revenue, or angry users. Money and inventory together demand explicit recovery stories.
| # | Failure point | What breaks | Detection | Mitigation design |
|---|---|---|---|---|
| FP1 | Concurrent hold on same seat | Two users both see available | Double booking same seat_id | Conditional UPDATE / version; transaction per cart |
| FP2 | Redis cache vs DB drift | Cache says available, DB sold | Hold succeeds then rejected late | Authoritative DB on hold; cache write-through on change |
| FP3 | Hold TTL sweeper lag | Expired holds not released | Artificial sold-out | Reliable scheduler; index on hold_expires_at |
| FP4 | Payment succeeded, confirm failed | User charged; seats still held | Support tickets; reconciliation mismatch | Outbox + confirm worker; idempotent confirm |
| FP5 | Payment failed, release missed | Seats held until TTL | Inventory locked 10 min | Compensating release in checkout catch block |
| FP6 | Duplicate checkout retry | Client double-submits order | Duplicate orders same reservation | Idempotency-Key; unique order per reservation |
| FP7 | Waiting room bypass | Bots hit hold API directly | Inventory DB overload | Require shop JWT; rate limit; WAF |
| FP8 | Partial multi-seat hold | 2 of 3 seats held; third taken | Broken cart | Single TX hold all seats or rollback all |
| FP9 | Event shard hotspot | All writes one event_id partition | DB CPU 100% on mega on-sale | Shard inventory by section; Redis serial queue per section |
| FP10 | Clock skew on hold_expires_at | Early release or late release | User loses cart early | DB monotonic time; grace period; extend on payment pending |
flowchart LR
Q[Queue] -->|FP7| GW[Gateway]
GW --> RES[Reservation]
RES -->|FP1 FP8| INV[Inventory DB]
MAP[Seat map cache] -->|FP2| RES
RES --> BK[Booking]
BK -->|FP4 FP5 FP6| PAY[Payment]
BK --> INV
SW[Sweeper] -->|FP3 FP10| INV
8. Failure modes
8.1 Double booking (oversell)
Symptom: Two confirmations for seat A12.
Cause: FP1 — race without atomic update; or FP2 cache lie.
Safe response: DB conditional update; audit sold seats; manual refund + alternate seat policy.
8.2 Ghost availability (stale map)
Symptom: User selects seat; hold returns 409.
Cause: FP2 — cached bitmap stale.
Safe response: Expected occasionally; refresh map; server is source of truth on hold.
8.3 Inventory lock-up (phantom holds)
Symptom: Show sold out but seats empty in venue.
Cause: FP3 sweeper stuck; mass abandoned carts.
Safe response: Alert on held > X% capacity; force-release job; shorten TTL on on-sale.
8.4 Paid but no ticket (confirm lag)
Symptom: Card charged; app says “processing”.
Cause: FP4 — crash after PSP success before SOLD.
Safe response: Reconciliation completes order or auto-refund; email user with ticket when fixed.
8.5 Ticket but no payment (rare, critical)
Symptom: Tickets issued; PSP shows failed.
Cause: Bug ordering confirm before payment result.
Safe response: Never mark SOLD before payment authorization; state machine guards.
8.6 Duplicate charge
Symptom: User charged twice for one cart.
Cause: FP6 — retry without idempotency.
Safe response: Idempotency-Key on order + PSP; return same order_id.
8.7 Queue fairness collapse
Symptom: Bots scoop best seats in seconds.
Cause: FP7 — API bypass; scalper farms.
Safe response: Shop token binding; device attestation; purchase limits per account.
8.8 Partial cart failure
Symptom: User holds 2 seats, only 1 at checkout.
Cause: FP8 — non-transactional multi-seat hold.
Safe response: All-or-nothing transaction; clear error UX.
8.9 On-sale database meltdown
Symptom: 503 for everyone at 10:00 AM.
Cause: FP9 — no queue; unbounded holds hit DB.
Safe response: Waiting room; section sharding; pre-warm connections.
8.10 Early cart expiry
Symptom: User payment fails at 9:59; hold gone at 10:00.
Cause: FP10 — clock skew; no grace extension.
Safe response: Extend hold while payment in-flight; show countdown from server time.
| Failure mode | Primary failure points | Impact | Core mitigation |
|---|---|---|---|
| Double booking | FP1, FP2 | Legal/reputation | Atomic DB update |
| Stale map | FP2 | UX frustration | Hold is authoritative |
| Phantom holds | FP3 | Lost sales | Sweeper + monitoring |
| Charged no ticket | FP4 | Support load | Reconciliation saga |
| Ticket no pay | Booking order | Revenue loss | State machine ordering |
| Duplicate charge | FP6 | Refunds | Idempotency |
| Bot scoop | FP7 | Fairness | Queue + tokens |
| Partial hold | FP8 | Broken checkout | Single TX |
| DB meltdown | FP9 | Outage | Queue + shard |
| Early expiry | FP10 | Abandoned pay | Grace + server clock |
9. Scalability, availability, and security
9.1 Scalability
- Separate read (seat map CDN) from write (inventory) paths.
- Shard inventory by
event_idorsection_id. - Queue absorbs flash; cap admitted RPS to DB capacity.
- Async email tickets; sync only inventory + payment confirm.
9.2 Availability
- Browse catalog from replicas; inventory primary for holds.
- Degraded mode: pause new holds, finish in-flight checkouts.
- Multi-AZ DB with failover; test connection storm on on-sale.
9.3 Security
- Rate limits per IP and account; bot detection on queue entry.
- Shop token bound to user session; cannot transfer cart easily.
- Audit log every state transition on seat rows.
10. Tradeoffs recap
| Decision | Common choice | Why |
|---|---|---|
| Inventory authority | SQL conditional UPDATE | Correctness over Redis speed |
| Flash traffic | Virtual waiting room | Protect DB |
| Hold TTL | 10 minutes | Balance UX vs inventory turnover |
| Seat map | Cached bitmap + authoritative hold | Read scale |
| Payment | Confirm after auth success | No ticket without pay |
11. How to present this in 45 minutes
- 5 min — requirements; fixed inventory; on-sale spike story.
- 7 min — capacity: queue admission vs raw RPS; hold write rate.
- 8 min — state machine; diagram; atomic hold SQL.
- 8 min — waiting room, seat map cache, payment saga.
- 10 min — failure points + failure modes (oversell, paid-no-ticket, phantom holds).
- 7 min — GA vs assigned seats, tradeoffs, link to payment idempotency.
The one line to remember
Event booking is inventory correctness under flash traffic: admit users through a queue, move seats only with atomic available→held→sold transitions, expire holds reliably, and tie payment to confirm with an idempotent saga so you never oversell or double-charge.