Hard

Design a Ticket Booking System (Ticketmaster)

A ticketing platform where millions of fans compete for tens of thousands of seats the moment a sale opens. The core problems are correctness (never sell one seat twice) under extreme write contention, temporary seat holds with TTL, and absorbing flash-sale spikes with a virtual waiting room so the transactional core stays within capacity.

1Requirements

Functional

  • Browse events and view a real-time seat map with availability
  • Hold selected seats for a limited window (e.g. 8 minutes) while the user pays
  • Confirm purchase: charge payment and convert held seats to sold atomically
  • Release holds automatically on expiry or explicit cancellation
  • Admit users through a fair virtual waiting room when demand exceeds capacity
  • Never oversell: each seat is sold to exactly one buyer

Non-functional

  • Strong consistency for seat state transitions; overselling is a correctness failure, not a degradation
  • Absorb flash spikes of 1M+ concurrent users against ~50k seats without collapsing the core
  • Hold operations complete in under 300 ms even at peak
  • Fairness: waiting-room ordering resists bots and refresh-abuse
  • High availability for browsing even when purchasing is saturated; reads must not be blocked by write contention

2Back-of-envelope estimation

Flash-sale audience1 million users at T-0
Seats on sale50,000
Peak seat-map read QPS~500,000
Sustainable hold QPS~2,000
Sell-out time~10-25 minutes
Hold state sizetrivial: 50k seats x ~100 B = 5 MB

3API design

GET /v1/events/{id}/seats

Seat map with availability. Served from a Redis-backed cache updated by change events; availability may be seconds stale, which the hold step reconciles.

POST /v1/events/{id}/holds

Body: { seat_ids: [...] }. Requires a valid waiting-room admission token. Atomically holds all seats or none; returns hold_id and expires_at. 409 with the contested seat ids on conflict.

POST /v1/holds/{hold_id}/purchase

Body: { payment_method, idempotency_key }. Charges payment and flips held seats to sold in one transaction. Idempotency key makes client retries safe.

DELETE /v1/holds/{hold_id}

Explicit release when the user abandons; otherwise TTL expiry reclaims the seats.

GET /v1/waiting-room/{event_id}/status

Long-poll/SSE endpoint returning queue position and, once admitted, a signed admission token with a short expiry.

4High-level design

The architecture is a funnel with three pressure zones. Zone 1 (browse) is read-only and cache-served: CDN for static assets, Redis pub-sub or SSE for seat-map deltas, and it must survive 500k QPS untouched by transactions. Zone 2 (waiting room) is the admission valve: it queues the 1M-user stampede and releases users into zone 3 at a rate the core can handle. Zone 3 (transact) is a strongly consistent inventory service on a relational database where holds and purchases mutate seat rows under locks.

Seat inventory is modeled as one row per seat per event with a state machine: available -> held -> sold, plus held -> available on expiry. The invariant 'a seat has at most one active hold or sale' is enforced by the database itself, not application logic: either row locks (SELECT ... FOR UPDATE, flip state only if currently available) or optimistic conditional updates (UPDATE ... WHERE status = 'available', check rows affected). Both make double-selling impossible at the storage layer, which is where correctness guarantees belong.

Holds carry a TTL (expires_at). Expiry is enforced lazily and eagerly at once: lazily, every read and hold attempt treats an expired hold as available (WHERE status='available' OR (status='held' AND hold_expires_at < now())), so correctness never depends on a timer firing; eagerly, a sweeper flips expired rows back in batches so seat maps and counts stay tidy. This lazy-check pattern is the crucial trick: TTL cleanup jobs can lag without ever causing incorrect behavior.

The purchase step spans two systems (inventory DB and payment provider), which is a distributed transaction in disguise. The standard resolution: hold seats first, then charge payment with an idempotency key, then mark sold in a local transaction; if the charge succeeds but the confirm write fails, a reconciliation worker replays the confirm using the payment provider's records as truth. The hold TTL is deliberately longer than any payment-provider timeout so the seat cannot be given away while a charge is in flight.

The virtual waiting room is what turns an impossible load problem into a solved one. Users arriving before or at on-sale join a queue (Redis sorted set scored by arrival time plus anti-bot checks); a gatekeeper admits N users per second, N tuned to keep zone 3 below its measured capacity, issuing signed, short-lived admission tokens that the hold API requires. Everyone else sees an honest position indicator. This converts a 1M-QPS thundering herd into a steady 2k QPS the database shrugs at, and fairness becomes an explicit, auditable policy rather than an accident of who retried fastest.

5Data model

seats

seat_id (PK), event_id, section, row, number, price_tier, status (available|held|sold), hold_id, hold_expires_at, version

One row per seat per event; composite index on (event_id, status). The version column supports optimistic concurrency if you avoid row locks

holds

hold_id (PK), event_id, user_id, seat_ids, created_at, expires_at, state (active|expired|converted|cancelled)

Groups a multi-seat selection so purchase converts all-or-nothing

orders

order_id (PK), user_id, event_id, seat_ids, amount, payment_intent_id, idempotency_key (unique), status (pending|paid|failed|refunded), created_at

Unique idempotency_key makes retried purchase calls return the same order instead of double-charging

waiting_room_entries

event_id, user_id, joined_at, position_score, admitted_at, token_hash

Backed by a Redis sorted set at runtime; persisted for audit and abuse analysis

6Deep dives

Preventing overselling: pessimistic vs optimistic seat locking

Pessimistic locking wraps the hold in a transaction: SELECT ... FOR UPDATE on the chosen seat rows, verify all are available (or expired-held), set them held, commit. Correctness is trivial to reason about and multi-seat holds are naturally atomic. The risks are lock waits under contention and deadlocks when two users pick overlapping seat sets in different orders; the fixes are always locking seat ids in sorted order and using NOWAIT or a short lock_timeout so contenders fail fast with a clean 409 rather than queueing.

Optimistic (conditional update) locking skips explicit locks: UPDATE seats SET status='held', hold_id=$h WHERE seat_id = ANY($ids) AND (status='available' OR (status='held' AND hold_expires_at < now())), then check that rows_affected equals the number requested; if not, roll back and report which seats were lost. Under a flash sale, contention on popular seats is ferocious and optimistic retries can livelock, so pessimistic-with-NOWAIT usually wins for hot events while optimistic is fine for the long tail. Both are correct; the choice is about wasted work under contention.

What does not work: checking availability in application code and then writing (check-then-act race), enforcing uniqueness in a cache without the DB as backstop, or relying on the seat map UI as any kind of guard. The database constraint is the last line of defense and must hold even if every layer above it is buggy. A belt-and-suspenders addition: a partial unique index on (event_id, seat_id) WHERE status='sold' in the orders path makes double-sale physically unrepresentable.

Hold TTL mechanics and the payment race

The hold window is a product decision with systems consequences: 8 minutes is common. Implement expiry as data, not as a scheduled action: the row stores hold_expires_at, and every state transition predicate treats an expired hold as available. A background sweeper (UPDATE seats SET status='available', hold_id=NULL WHERE status='held' AND hold_expires_at < now() LIMIT batches) is purely hygienic, restoring seats to the visible pool promptly, and its failure mode is cosmetic staleness rather than incorrectness.

The nasty race is purchase-at-expiry: the user submits payment at 7:59, the charge takes 20 seconds, and meanwhile the hold expires and another user grabs the seat. Two defenses compose. First, the confirm transaction re-checks the hold is still active and owned by this user before flipping to sold; if the hold lapsed, refund automatically and apologize. Second, prevent the window: the purchase endpoint refuses to start a charge in the final 60 seconds of a hold unless it first extends the hold (an extension is just an atomic conditional UPDATE on hold_expires_at, allowed once), sized so hold-extension >= payment-provider max timeout.

Idempotency ties it together: the client sends an idempotency key with purchase; the orders table has a unique constraint on it; a retry after a network blip finds the existing order and returns it instead of re-charging. The same key is forwarded to the payment provider (Stripe-style) so even the charge itself is exactly-once from the user's perspective.

The virtual waiting room: fairness as load shedding

Without admission control, on-sale moment traffic is a self-DDoS: 1M users hammering hold endpoints for 50k seats does 950k units of guaranteed-wasted work, and the contention collapses throughput for everyone including the eventual winners. The waiting room inverts this: absorb arrivals into a cheap queue (a Redis sorted set insert is microseconds), admit at the transactional core's measured capacity, and reject nothing; users just wait with an honest position.

Fairness design is where the interesting choices live. Score by arrival time and admission is FIFO, which feels fair but rewards bots that arrive first with thousands of connections; score by a random lottery among everyone present at T-0 and bots gain nothing from arriving early but humans who queued get no credit. Real systems blend: randomize within arrival cohorts, require a signed browser challenge or CAPTCHA to join, limit entries per account and payment fingerprint, and make the admission token single-use, user-bound, and short-lived so it cannot be resold or shared.

The admission token is the enforcement point: a signed JWT-style blob (event_id, user_id, admitted_at, expiry, nonce) that the hold API verifies statelessly plus a Redis single-use check on the nonce. Rate-tune admissions with a feedback loop: watch hold-endpoint p99 and DB lock waits, and shrink the admit rate when the core approaches saturation. This is a control system, not a static config.

Serving the seat map under 500k QPS of reads

Seat-map reads outnumber transactional writes by orders of magnitude and must never touch the locked tables. The pattern is read-path/write-path separation: the inventory service emits a change event (seat X held/sold/released) on every commit, a fan-out layer folds these into a compact per-event availability bitmap or summary in Redis, and clients get deltas over SSE/WebSocket or poll a CDN-cacheable snapshot with a 1-2 second TTL.

Staleness is embraced, not fought: a user may click a seat that was grabbed 800 ms ago, and the hold request comes back 409 with the current truth. The UX contract is 'the map is advisory; the hold is authoritative.' Optimizing the miss rate matters (fresher maps mean fewer failed holds and less wasted core capacity) but correctness never depends on map freshness.

For general-admission or price-tier sales (no assigned seats), replace per-seat rows with an atomic counter: Redis DECRBY with a Lua script that refuses to go below zero, write-behind to the DB, or a single-row UPDATE inventory SET remaining = remaining - $n WHERE remaining >= $n. Counters remove per-seat contention entirely and are why GA on-sales survive spikes that seated maps struggle with; mention this contrast to show you see the data-model lever, not just the infrastructure one.

7Rapid implementation: build the MVP

Theory is table stakes. Here is how you would stand up a working version fast, on a budget, with the core algorithm in real code.

Stack

Postgres (Supabase free tier) for inventory, Upstash Redis for the waiting room and seat-map cache, Next.js on Vercel, Stripe test mode for payments

  1. 01Create the seats table (one row per seat, status + hold_expires_at columns) and seed a 500-seat venue for one event
  2. 02Implement POST /holds as a single transaction: SELECT ... FOR UPDATE NOWAIT on sorted seat ids, verify each is available or expired-held, set held with an 8-minute expiry
  3. 03Implement purchase: re-verify the hold under FOR UPDATE, create a Stripe test-mode PaymentIntent with the client's idempotency key, then mark seats sold and the order paid in one commit
  4. 04Add lazy expiry to every predicate (status='available' OR hold_expires_at < now()) and a 30-second sweeper cron that releases expired holds with SKIP LOCKED
  5. 05Build the seat map endpoint from a Redis hash (seat_id -> status) updated after every commit, with a 2-second client poll
  6. 06Add the waiting room: ZADD users into a Redis sorted set on arrival, a gatekeeper loop that pops the lowest N scores per second and writes single-use signed admission tokens, and token verification middleware on the hold route
  7. 07Write a contention test: fire 200 concurrent hold requests at the same 5 seats with autocannon and assert exactly 1 winner per seat and zero rows with status='sold' duplicated
  8. 08Simulate the flash sale end to end: 2,000 scripted users through the waiting room against 500 seats, verify sell-out with zero oversells and honest queue positions

Atomic multi-seat hold with FOR UPDATE NOWAIT and lazy expiry

sql
BEGIN;

-- Lock in sorted order to prevent deadlocks; NOWAIT fails fast under contention.
SELECT seat_id, status, hold_expires_at
FROM seats
WHERE event_id = $1 AND seat_id = ANY($2)   -- $2 must be sorted by caller
ORDER BY seat_id
FOR UPDATE NOWAIT;

-- Application verifies every returned row is grabbable, then:
UPDATE seats
SET    status = 'held',
       hold_id = $3,
       hold_expires_at = now() + interval '8 minutes'
WHERE  event_id = $1
  AND  seat_id = ANY($2)
  AND  (status = 'available'
        OR (status = 'held' AND hold_expires_at < now()));
-- If row_count <> array_length($2), another user won a seat: ROLLBACK and 409.

COMMIT;

Purchase confirmation with idempotency and hold re-check

typescript
import { pool } from "./db";
import { stripe } from "./stripe";

export async function purchase(holdId: string, userId: string, idemKey: string) {
  const existing = await pool.query(
    "SELECT order_id, status FROM orders WHERE idempotency_key = $1", [idemKey]);
  if (existing.rows[0]) return existing.rows[0]; // safe retry

  const client = await pool.connect();
  try {
    await client.query("BEGIN");
    const hold = await client.query(
      "SELECT * FROM holds WHERE hold_id = $1 AND user_id = $2 " +
      "AND state = 'active' AND expires_at > now() FOR UPDATE", [holdId, userId]);
    if (!hold.rows[0]) throw new Error("HOLD_EXPIRED");

    // Charge outside any seat-row locks; idempotency key makes it retry-safe.
    const intent = await stripe.paymentIntents.create(
      { amount: hold.rows[0].amount, currency: "usd", confirm: true,
        payment_method: hold.rows[0].payment_method },
      { idempotencyKey: idemKey });

    await client.query(
      "UPDATE seats SET status = 'sold' WHERE hold_id = $1 AND status = 'held'", [holdId]);
    await client.query(
      "UPDATE holds SET state = 'converted' WHERE hold_id = $1", [holdId]);
    await client.query(
      "INSERT INTO orders (user_id, seat_ids, amount, payment_intent_id, idempotency_key, status) " +
      "VALUES ($1, $2, $3, $4, $5, 'paid')",
      [userId, hold.rows[0].seat_ids, hold.rows[0].amount, intent.id, idemKey]);
    await client.query("COMMIT");
    return { status: "paid" };
  } catch (err) {
    await client.query("ROLLBACK");
    throw err; // reconciler replays confirms for succeeded charges
  } finally {
    client.release();
  }
}

Waiting-room gatekeeper (Redis sorted set admission)

typescript
import { createHmac, randomUUID } from "crypto";
import { redis } from "./redis";

const ADMIT_PER_SECOND = 50; // tune against measured hold-endpoint capacity

export async function joinQueue(eventId: string, userId: string) {
  await redis.zadd("wr:" + eventId, { score: Date.now(), member: userId });
}

export async function gatekeeperTick(eventId: string) {
  const admitted = await redis.zpopmin("wr:" + eventId, ADMIT_PER_SECOND);
  for (const { member: userId } of admitted) {
    const nonce = randomUUID();
    const exp = Date.now() + 10 * 60 * 1000;
    const payload = [eventId, userId, exp, nonce].join(".");
    const sig = createHmac("sha256", process.env.WR_SECRET!).update(payload).digest("hex");
    await redis.set("wrtok:" + nonce, userId, { ex: 600 }); // single-use marker
    await redis.set("wradmit:" + eventId + ":" + userId, payload + "." + sig, { ex: 600 });
  }
}

export async function verifyToken(token: string): Promise<boolean> {
  const parts = token.split(".");
  const sig = parts.pop();
  const [, , exp, nonce] = parts;
  const expected = createHmac("sha256", process.env.WR_SECRET!)
    .update(parts.join(".")).digest("hex");
  if (sig !== expected || Number(exp) < Date.now()) return false;
  const used = await redis.getdel("wrtok:" + nonce); // atomic single-use burn
  return used !== null;
}

Bottlenecks & failure modes

  • Row-lock contention on popular seats (front row) serializes holds; lock in sorted seat order with NOWAIT, fail fast to 409, and let the map steer users apart
  • The on-sale thundering herd: without a waiting room, wasted-work traffic collapses the core; admission control is the fix, not bigger databases
  • Hold-expiry sweeps competing with live traffic for the same rows; sweep in small batches with SKIP LOCKED and rely on lazy expiry checks for correctness
  • Payment provider latency (seconds) inside the user's hold window; never hold DB locks across the payment call, and extend holds before charging
  • Seat-map fan-out at 500k QPS; push deltas via pub-sub and CDN-cache snapshots so reads never reach Postgres

Key takeaways

  • Enforce the no-oversell invariant in the database (conditional updates or row locks), never in application checks or caches
  • Model hold expiry as data checked lazily on every transition; background cleanup is hygiene, not correctness
  • A virtual waiting room converts an unbounded spike into a tunable admission rate and makes fairness an explicit policy
  • Bridge inventory and payment with idempotency keys plus reconciliation, not a distributed transaction protocol
  • Separate read and write paths: advisory cached seat maps absorb 99 percent of traffic so locks only serialize real intent

Brush up on the underlying topics