Guide contents

Lesson 113 of 129

Design a Ticket Booking System

A ticket booking system sells a fixed set of seats for events such as concerts, movies, and matches. It resembles a hotel reservation system in its no-overselling requirement, but differs in two ways: each seat is a distinct item that users choose individually, and demand for a popular event arrives as a spike of hundreds of thousands of users in the same minute.

Requirements

Functional

  • Browse and search events by city, date, and category.
  • View the seat map of an event with seat status (available, held, sold) and prices.
  • Select seats; the system holds them temporarily while the user pays.
  • Pay and receive tickets; a seat is sold to at most one buyer.
  • Cancel or let the hold expire; seats return to the available pool.

Non-functional

  • Correctness: no seat is sold twice. This has priority over availability of the booking path.
  • Surge tolerance: on-sale moments produce a spike that can be several orders of magnitude above baseline.
  • Fairness: users who arrive first should have a reasonable chance; bots should be limited.
  • Low latency for seat map views; the seat map may be slightly stale.
  • Holds expire automatically without manual cleanup.

Estimation

Assumptions: 10 million tickets sold per day across all events; one popular event with 50,000 seats goes on sale with 500,000 users online at the start.

  • Average sales: 10,000,000 / 86,400 is about 116 tickets per second. Trivial for a database.

  • On-sale surge: 500,000 users open the seat map within about 1 minute. That is about 8,300 page loads per second, each with a seat map read; users refresh repeatedly, so assume 5 requests per user, about 40,000 requests per second for that one event.

  • Seat state: 50,000 seats x 50 bytes = 2.5 MB per event. The whole seat map of one event fits in memory and in a single response.

  • Holds: if each successful hold lasts 5 minutes and 50,000 seats sell out in a few minutes, the peak number of concurrent holds is bounded by the seat count, 50,000.

  • Orders per year: 10,000,000 x 365 x 500 B = about 1.8 TB.

  • Bandwidth: an uncompressed 2.5 MB seat map at 40,000 requests per second would be 100 GB/s, which no origin can serve. With gzip (highly repetitive JSON compresses about 10x) and a compact encoding (1 byte of state per seat is 50 KB per event), it drops to about 50 KB x 40,000 = 2 GB/s, which is CDN-only territory. Send the full map once and deltas afterwards.

  • Hold writes: if 50,000 seats sell out in 5 minutes, that is about 170 successful holds per second plus failed attempts. Without admission control, failures dominate: 500,000 users each retrying several times gives tens of thousands of conditional updates per second against one shard.

What the numbers imply: the average load (116 per second) is irrelevant; sizing is set by the on-sale spike for one event. Three consequences: (1) the seat map must be a cached, compact snapshot, not a database query; (2) contention is on one shard, so admission control (bounding concurrent writers) is required, not optional; (3) the working set is tiny (2.5 MB per event), so the state for a hot event could live in memory with the database as the durable record.

The problem is a contention and read-spike problem on a small dataset, not a data volume problem.

API design

Code
GET  /v1/events?city=&date=&q=                         -> events
GET  /v1/events/{event_id}/seats                       -> seat map with status per seat
POST /v1/events/{event_id}/holds   { seat_ids, idempotency_key } -> { hold_id, expires_at } | 409 conflict
POST /v1/orders                    { hold_id, payment_method, idempotency_key } -> { order_id, status }
DELETE /v1/holds/{hold_id}         (release)
GET  /v1/orders/{order_id}         -> order and tickets

A hold returns 409 Conflict if any requested seat is not available. The client shows the updated seat map. Holds and orders both take idempotency keys.

Data model

Table Key columns Notes
event event_id, venue_id, start_time, on_sale_at Read-mostly
seat event_id, seat_id, section, row, number, price_tier Static per event
seat_status event_id, seat_id, status (AVAILABLE, HELD, SOLD), hold_id, hold_expires_at, version Hot table
hold hold_id, event_id, user_id, seat_ids, expires_at Temporary
order order_id, user_id, hold_id, amount, status (PENDING, PAID, FAILED, REFUNDED), idempotency_key Durable
ticket ticket_id, order_id, event_id, seat_id, barcode Issued after payment

A relational database with row-level transactions is appropriate (see ACID and isolation): seat status transitions need atomic conditional updates. Shard by event_id so that all seats of one event share a shard and a multi-seat hold is a single-shard transaction. The tradeoff is that one hot event loads one shard, which the design addresses with admission control below.

The seat state machine:

stateDiagram-v2
    [*] --> AVAILABLE
    AVAILABLE --> HELD: hold (conditional update)
    HELD --> AVAILABLE: hold expires or released
    HELD --> SOLD: payment succeeded
    SOLD --> AVAILABLE: refund and re-release

High-level design

flowchart LR
    U["Users"] --> CDN["CDN and edge"]
    CDN --> WR["Virtual waiting room / admission control"]
    WR --> GW["API gateway"]
    GW --> ES["Event service (browse, search)"]
    GW --> SS["Seat service (seat map)"]
    GW --> BS["Booking service (holds, orders)"]
    SS --> RC[("Cache: seat map snapshots")]
    BS --> DB[("Seat and order DB (sharded by event)")]
    BS --> PS["Payment service"]
    BS --> Q[["Event bus"]]
    Q --> TS["Ticket issuing service"]
    Q --> NS["Notification service"]
    EXP["Hold expiry worker"] --> DB
    ES --> EDB[("Event DB and search index")]
  • Event service. Read-only browse and search. Fully cacheable and served from a CDN where possible.
  • Seat service. Serves the seat map. Reads a snapshot from cache refreshed every second or so, or updated by change events.
  • Booking service. Creates holds and orders and is the only writer of seat status.
  • Hold expiry worker. Returns expired holds to AVAILABLE.
  • Waiting room. Limits how many users reach the booking path at once.
  • Ticket service. Issues barcodes after payment, asynchronously.

Deep dive

1. Holding seats without selling twice

The hold is an atomic conditional update. For each requested seat:

Code
UPDATE seat_status
   SET status = 'HELD', hold_id = ?, hold_expires_at = now() + interval '5 minutes', version = version + 1
 WHERE event_id = ? AND seat_id = ?
   AND (status = 'AVAILABLE' OR (status = 'HELD' AND hold_expires_at <= now()));

If the number of affected rows is less than the number of seats requested, the transaction rolls back and returns 409. Seats are updated in a fixed order (by seat id) to avoid deadlocks between users choosing overlapping sets. Including the expiry condition in the WHERE clause means a stale hold is reclaimed on demand even if the background worker is late, so correctness never depends on the worker.

Other mechanisms and their trade-offs:

Mechanism Description Trade-off
Conditional update (above) Single atomic statement per seat Simple and correct; relies on database as the serialization point
Redis lock with TTL per seat SET seat:{id} hold_id NX EX 300 Fast, TTL built in; the source of truth must still be the database, and Redis failover can lose a lock, so the final sale must still be verified in the database
Optimistic version check WHERE version = ? Fine at low contention; many retries for hot seats
Per-event single-threaded actor One process serializes all operations for an event No lock contention; the actor is a single point of failure and throughput limit unless replicated with consensus

Why chosen: the conditional update makes the database row the single serialization point, it needs no separate lock service, and expiry is checked in the same statement, so there is no lock-versus-database divergence to reconcile. Consistency model: the hold and sale are linearizable per seat (single primary, synchronous replica); the seat map is eventually consistent by design (1 to 2 seconds stale). Use READ COMMITTED; the update's row lock is what serializes competing holds, and the losers see zero affected rows within a few milliseconds. Shard key event_id keeps a multi-seat hold to one transaction; the hot-shard answer is a dedicated shard for a known big on-sale plus the admission cap, and, if one event still saturates one primary (about 5,000 to 10,000 conditional updates per second), sharding by section within the event, with multi-section orders split into separate holds and a saga to release all if one fails. Cap seats per hold (for example 8) to bound the transaction size and lock time. Hold TTL: 5 minutes for a first hold, extended by a fixed 2 to 3 minutes once payment starts; never let a client extend indefinitely.

The sketch below models hold expiry logic with a logical clock (a plain number of seconds passed in by the caller) so the outcome is deterministic. A sealed interface lists the three seat states, and the switch covers each one without a default.

Java
public class Main {
    sealed interface Status {
        record Available() implements Status {}

        record Held(int hold, long expires) implements Status {}

        record Sold() implements Status {}
    }

    static final class Seat {
        private Status status = new Status.Available();

        // synchronized stands in for the row lock taken by the conditional UPDATE
        synchronized boolean tryHold(int hold, long now, long ttl) {
            boolean free = switch (status) {
                case Status.Available a -> true;
                case Status.Held h -> h.expires() <= now; // an expired hold can be reclaimed
                case Status.Sold s -> false;
            };
            if (free) {
                status = new Status.Held(hold, now + ttl);
            }
            return free;
        }

        synchronized Status status() {
            return status;
        }
    }

    public static void main(String[] args) {
        var seat = new Seat();
        System.out.println("t=0 user A: " + seat.tryHold(1, 0, 300));
        System.out.println("t=100 user B: " + seat.tryHold(2, 100, 300));
        System.out.println("t=301 user B: " + seat.tryHold(2, 301, 300));
        System.out.println(seat.status());
    }
}
Output
t=0 user A: true
t=100 user B: false
t=301 user B: true
Held[hold=2, expires=601]

2. Hold, pay, and confirm

sequenceDiagram
    participant U as User
    participant B as Booking service
    participant DB as Seat DB
    participant P as Payment service
    U->>B: hold seats [A1, A2]
    B->>DB: conditional update to HELD (5 min)
    DB-->>B: 2 rows
    B-->>U: hold_id, expires_at
    U->>B: create order (hold_id)
    B->>DB: verify hold still valid, insert order PENDING
    B->>P: charge (idempotency key)
    P-->>B: success
    B->>DB: seats to SOLD, order PAID (one transaction, checks hold_id)
    B-->>U: confirmation

Rules:

  • The final HELD to SOLD update checks hold_id matches and the hold has not been reclaimed. If the payment succeeded after the hold expired and another user took the seats, the system refunds the payment. To make this rare, the payment step has a shorter timeout than the hold, and the hold is extended once payment has started.
  • Payment provider callbacks can be duplicated; the transition is idempotent (see delivery semantics and idempotency).
  • Ticket issuance and emails run asynchronously from the event bus after the PAID commit.

3. Surviving the on-sale spike

Reads: the seat map for one event is 2.5 MB at most and can be cached as a snapshot. Serve it from cache or CDN with a TTL of one to two seconds. Delta updates (only seats whose state changed) can be pushed through WebSockets or SSE. Slight staleness is acceptable because the hold operation is authoritative.

Writes: the database can handle 116 sales per second easily, but 500,000 users trying to hold seats at once would produce contention and retries. Controls:

  • Virtual waiting room. At on-sale time, users enter a queue and receive a position (a random or first-come order assigned at arrival). The system admits users into the booking flow at a fixed rate matching capacity: for example, if the booking path sustains 1,000 concurrent sessions and a session lasts about 5 minutes, admission is about 200 users per minute on average, adjusted as seats sell out. Admission uses a signed token so the booking API can verify it without a lookup.
  • Rate limiting per user, IP, and device (see rate limiting algorithms), and bot detection (challenge before admission), and a limit on tickets per order.
  • Per-event isolation. A hot event runs on its own shard or cluster so it cannot degrade other events.
  • Load shedding. Reject excess requests early at the edge with a retry hint instead of queueing unbounded work.

Waiting room internals: the queue lives outside the booking path (a separate, horizontally scaled service, with a static "you are in line" page from the CDN). A user receives a random rank when arriving in the pre-sale window (for example 10 minutes before), so arrival within that window carries no advantage and the on-sale instant does not need a thundering-herd race. Position is stored as a small record per user (about 100 B, so 500,000 users is 50 MB in Redis). Clients poll every 5 to 10 seconds with jitter (500,000 / 7 s is about 70,000 polls per second, served from the edge with a signed status token) or hold an SSE connection. Alternatives: pure first-come queue (rewards bots and fast connections) or lottery with pre-registration (fairest, adds a step). Admitted tokens expire after about 10 minutes and are single-user.

Seat-map delivery detail: a full snapshot of 50 KB plus deltas by version number; a client that misses versions refetches the snapshot. Deltas are batched every 500 ms to cap push volume at a few KB per second per client.

4. Best-available seat selection and hold expiry

For general-admission or best-available modes, the user requests N seats without choosing. The service finds N adjacent available seats in a section by scanning a bitmap of seat states for that section held in memory, then attempts the conditional hold. If the hold fails because another user took a seat, the service tries the next candidate set. Expiry: the worker runs every few seconds, selecting rows WHERE status = 'HELD' AND hold_expires_at <= now() (indexed) and resetting them, so that seat maps and availability counts converge to the correct state even without new hold requests.

Failure modes

Failure What breaks Degradation Recovery
Seat shard primary fails Holds and sales for its events Those events reject bookings (503 with retry hint); other events unaffected; browsing continues from cache Promote the synchronous replica (tens of seconds); no confirmed sale lost
Payment provider slow or down Orders stay PENDING Holds expire; users see "payment failed, seats released" Reconcile pending orders against the provider by idempotency key; refund if paid after expiry
Payment succeeds after hold reclaimed Two buyers claim a seat The second update fails its hold_id check Automatic refund to the late payer; alert if rate exceeds a small fraction of a percent
Cache or CDN down Seat map read spike hits origin Serve a coarse availability (sections with counts) instead of per-seat state Rebuild snapshot from the DB every second, one writer
Waiting room fails Unbounded users reach booking Edge rate limits and load shedding as a second line Fail closed: show the queue page, admit nobody until healthy
Expiry worker stops Availability displays stale Correctness intact, since holds are reclaimed in the WHERE clause Restart; displayed counts converge

At 10x scale (a stadium tour with 5 million concurrent users, 500,000 seats): the seat map is 500 KB per event compact, so section-level lazy loading is needed; the single-event shard is exceeded, forcing per-section sharding; the waiting room needs multi-region edge state; and admission rate becomes the main lever (for example 2,000 per second, sized to booking capacity, not to demand).

Bottlenecks and scaling

  • Seat DB hot shard: one event maps to one shard by design. Admission control keeps concurrent writers bounded. If needed, split a very large event by section, accepting that best-available search then spans shards.
  • Seat map reads: cache, CDN, delta pushes.
  • Payment latency: external and slow. It is outside the database transaction; holds cover the wait.
  • Expiry worker lag: harmless for correctness because the hold query reclaims expired holds itself; it only affects displayed availability.
  • Redis or cache loss: caches hold only derived data; the database is authoritative.
  • Database failover: use synchronous replication for the seat shard so a confirmed sale is never lost (see replication).

Trade-offs and follow-up questions

  • Hold duration: longer holds reduce lost payments but let abandoners block inventory. Typical values are a few minutes.
  • Fairness in the waiting room: random admission among early arrivals is harder to game than strict first-come.
  • Strict consistency for seat status versus caching: the seat map is a hint; only the hold and sale operations are consistent.
  • What if a user pays after the hold expires? The sale update checks hold_id and expiry; if the seat was taken, refund automatically. If nobody took it, the sale can still be accepted by re-holding in the same transaction.
  • What if Redis loses a seat lock? Harmless when Redis is only an accelerator: the database conditional update still rejects the second buyer. Redis-only locking would oversell.
  • What if two users want the last seat at the same instant? Both run the conditional update; the row lock serializes them, one gets 1 affected row, the other 0 and a 409.
  • What if the user has 8 seats and one is taken? The whole hold fails (all or nothing) in one transaction; the client shows alternatives. Partial holds are optional and must be an explicit product decision.
  • How do you avoid a thundering herd when the waiting room opens? Admit at a fixed rate with jittered start, and cap concurrent booking sessions (about 1,000 in the earlier example).
  • How do you count "sold out" reliably? A counter of AVAILABLE seats per event derived from the DB and cached, used for display only; the sale path never trusts it.
  • How to handle resale and transfers? Ticket ownership becomes a separate table with barcode rotation.
  • How to prevent scalpers? Purchase limits, identity checks, and bot mitigation; these are policy and detection problems, not consistency problems.
  • Why not use a distributed transaction across seat DB and payment provider? The provider is external and does not support two-phase commit; the hold with expiry and refund compensation replaces it (see saga pattern).