sharpbyte.dev

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:

1. Requirements gathering

1.1 Functional requirements

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

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)

ComponentInitial sizing
Edge + WAF + queueWaiting room token; 100k+ RPS static/browse
Inventory serviceSharded by event_id; primary DB + Redis hot path
Hold storeRedis TTL keys + DB authoritative state
Booking serviceState machine: held → paid → confirmed
PaymentExternal PSP; idempotency keys

3. High-level design

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}/queue202 with position, or 200 with shop token when admitted.

6. Diving deep into key components

6.1 Virtual waiting room

6.2 Preventing overselling

ApproachMechanismNotes
Optimistic lockingversion column on seat rowRetry on conflict
Conditional UPDATEWHERE status='available'Interview default
Pessimistic lockSELECT FOR UPDATESerializable per seat; slower
Distributed lockRedis Redlock per seatRisky 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

  1. On hold, set hold_expires_at = now + 10 min.
  2. Background job (every 30 s) releases seats where status=held AND hold_expires_at < now().
  3. Redis TTL key as hint; DB update is source of truth.
  4. User extending checkout: optional one-time +5 min if payment in progress.

6.4 Payment handoff (saga)

  1. Hold — inventory reserved.
  2. Charge — call PSP with idempotency key.
  3. Confirm — if payment OK, UPDATE seats SET status='sold' in same TX as order insert.
  4. 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

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

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 pointWhat breaksDetectionMitigation 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 modePrimary failure pointsImpactCore mitigation
Double bookingFP1, FP2Legal/reputationAtomic DB update
Stale mapFP2UX frustrationHold is authoritative
Phantom holdsFP3Lost salesSweeper + monitoring
Charged no ticketFP4Support loadReconciliation saga
Ticket no payBooking orderRevenue lossState machine ordering
Duplicate chargeFP6RefundsIdempotency
Bot scoopFP7FairnessQueue + tokens
Partial holdFP8Broken checkoutSingle TX
DB meltdownFP9OutageQueue + shard
Early expiryFP10Abandoned payGrace + server clock

9. Scalability, availability, and security

9.1 Scalability

9.2 Availability

9.3 Security

10. Tradeoffs recap

DecisionCommon choiceWhy
Inventory authoritySQL conditional UPDATECorrectness over Redis speed
Flash trafficVirtual waiting roomProtect DB
Hold TTL10 minutesBalance UX vs inventory turnover
Seat mapCached bitmap + authoritative holdRead scale
PaymentConfirm after auth successNo ticket without pay

11. How to present this in 45 minutes

  1. 5 min — requirements; fixed inventory; on-sale spike story.
  2. 7 min — capacity: queue admission vs raw RPS; hold write rate.
  3. 8 min — state machine; diagram; atomic hold SQL.
  4. 8 min — waiting room, seat map cache, payment saga.
  5. 10 minfailure points + failure modes (oversell, paid-no-ticket, phantom holds).
  6. 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.