System Design Interview: Ticketing and Seat Reservation System

Designing a ticketing system like Ticketmaster or a seat reservation system for movies/concerts tests your understanding of concurrency, distributed locking, and handling flash sale traffic spikes. It’s a common system design interview question at companies including Atlassian, DoorDash, and Airbnb.

Core Requirements

Functional: Browse events, view seat map with real-time availability, select seats (held for 10 minutes), complete purchase, receive tickets, cancel/transfer tickets.

Non-functional: Handle 100K concurrent users during popular event onsales, prevent double-booking (exactly-once seat assignment), < 200ms API response times, 99.99% availability.

The Core Problem: Preventing Double-Booking

Naive approach (WRONG — race condition):
  1. Client A: SELECT status FROM seats WHERE id=5 → available
  2. Client B: SELECT status FROM seats WHERE id=5 → available
  3. Client A: UPDATE seats SET status='sold' WHERE id=5
  4. Client B: UPDATE seats SET status='sold' WHERE id=5
  → Both succeed! Seat 5 is double-booked.

Fix 1: Pessimistic locking (SELECT FOR UPDATE)
  BEGIN;
  SELECT * FROM seats WHERE id=5 FOR UPDATE; -- acquires row lock
  -- only one transaction can hold this lock
  UPDATE seats SET status='reserved', held_by='user_A' WHERE id=5;
  COMMIT; -- lock released

Seat State Machine

Available → Held (10-min TTL) → Purchased
                ↓ (timeout or abandon)
            Available

Implementation:
  seats table:
    id          BIGINT PRIMARY KEY
    event_id    BIGINT
    section     VARCHAR
    row         CHAR
    number      INT
    status      ENUM('available', 'held', 'sold')
    held_by     UUID  -- user session ID
    held_until  TIMESTAMPTZ  -- expiry for held seats
    order_id    UUID  -- once sold

  Hold a seat:
    UPDATE seats
    SET status='held', held_by=?, held_until=NOW()+INTERVAL '10 min'
    WHERE id=? AND status='available'  -- ← optimistic: only succeeds if available
    RETURNING id;
    -- 0 rows updated = seat was taken, return error to client

  Expire held seats (background job every 30s):
    UPDATE seats
    SET status='available', held_by=NULL, held_until=NULL
    WHERE status='held' AND held_until < NOW();

Flash Sale Architecture: Handling Traffic Spikes

Problem: Taylor Swift onsale → 500K concurrent users hit "Buy" at 10:00 AM

Solution: Virtual Queue

  User flow:
    1. User registers for queue (before onsale)
    2. At onsale time, queue opens → users assigned random positions
    3. Users served in queue order with rate limiting
    4. Each user gets a time-boxed session (30 min) to complete purchase

  Architecture:
    Pre-queue:  SQS FIFO queue or Redis sorted set
                Zset score = random float (randomizes order)

    Queue service:
      → Polls queue, admits N users per minute (N = capacity)
      → Issues signed JWT token to admitted user: {user_id, valid_until, event_id}
      → User's requests must include this token

    Throttle: Nginx rate limit → 1000 req/s per event_id globally
              Token bucket per session prevents multiple purchases

Seat Map: Real-Time Availability

Availability display options:

Option A: REST polling (simple)
  Client polls GET /events/{id}/seats every 5 seconds
  Server queries Redis bitmap or seats table
  Pros: simple; Cons: stale data, unnecessary load

Option B: Server-Sent Events (SSE) — recommended
  Client: EventSource('/api/events/{id}/availability')
  Server: pushes seat status changes as they happen
  Changes: seat transitions to held/sold → publish to Redis Pub/Sub
           → SSE broadcaster → all subscribed clients
  Pros: real-time, unidirectional, auto-reconnect
  Cons: server holds connection per client (use connection pooling)

Option C: WebSocket
  Bidirectional — overkill for display-only availability
  Use for collaborative seat selection (multiple users see each other's cursors)

Redis bitmap for fast availability check:
  Key: avail:{event_id}
  Bit N = 1 if seat N available, 0 if held/sold
  BITCOUNT key → total available seats in O(1)
  SETBIT / GETBIT for individual seat check

Database Design

CREATE TABLE events (
    id          BIGSERIAL PRIMARY KEY,
    name        TEXT NOT NULL,
    venue_id    BIGINT,
    starts_at   TIMESTAMPTZ,
    onsale_at   TIMESTAMPTZ,
    status      TEXT DEFAULT 'upcoming'
);

CREATE TABLE seats (
    id          BIGSERIAL PRIMARY KEY,
    event_id    BIGINT REFERENCES events(id),
    section     TEXT,
    row_label   TEXT,
    seat_num    INT,
    price_cents INT,
    status      TEXT DEFAULT 'available',
    held_by     UUID,
    held_until  TIMESTAMPTZ,
    order_id    UUID,
    INDEX (event_id, status),
    INDEX (held_until) WHERE status = 'held'  -- partial index
);

CREATE TABLE orders (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id     BIGINT,
    event_id    BIGINT,
    status      TEXT DEFAULT 'pending',
    total_cents INT,
    created_at  TIMESTAMPTZ DEFAULT NOW(),
    expires_at  TIMESTAMPTZ  -- 10-min payment window
);

CREATE TABLE order_seats (
    order_id    UUID REFERENCES orders(id),
    seat_id     BIGINT REFERENCES seats(id),
    PRIMARY KEY (order_id, seat_id)
);

Purchase Flow with Idempotency

Checkout flow:
  1. User selects seats → POST /holds {seat_ids: [5, 6], session_id}
     → DB: UPDATE seats SET status='held' WHERE id IN (5,6) AND status='available'
     → If any seat already taken → 409 Conflict with specific seat IDs
     → Cache: set hold TTL in Redis for expiry tracking

  2. User enters payment → POST /orders {hold_id, payment_method_nonce}
     → Create order with status='pending', expires_at=NOW()+10min
     → Call payment provider (Stripe) with idempotency_key=order_id
       (idempotency_key prevents double-charge on retry)
     → On payment success:
       BEGIN;
       UPDATE orders SET status='confirmed' WHERE id=? AND status='pending';
       UPDATE seats SET status='sold', order_id=? WHERE id IN (5,6) AND held_by=session_id;
       COMMIT;
     → Generate ticket QR codes (signed JWTs with seat + order info)
     → Send confirmation email async (SQS → Lambda/worker)

  3. On payment failure:
     UPDATE seats SET status='available', held_by=NULL WHERE id IN (5,6)
     DELETE FROM orders WHERE id=?

Capacity Estimation

  • Concert venue: 50,000 seats per event
  • Peak onsale: 500K concurrent users for 5 minutes
  • Read traffic: 500K × 10 req/min = 83K req/sec (browsing, polling)
  • Write traffic: ~1K purchase attempts/sec at peak (10-min hold window)
  • DB: PostgreSQL handles ~10K TPS on seats table (row-level locking)
  • Redis: handles 1M ops/sec — seat availability cache is critical path

Interview Discussion Points

  • Why not Redis for seat holds instead of DB? Redis doesn’t support ACID transactions across seat + order tables. The final sold state must be durable — use Redis as a cache/pubsub layer, PostgreSQL for the source of truth.
  • How to handle payment provider timeout? Use async confirmation: POST /orders returns “pending” immediately. Stripe sends webhook on completion → update order status → send ticket. Idempotency key on Stripe charge prevents double-charging on retry.
  • How to scale DB for 500K concurrent users? Shard by event_id — each major event gets its own DB shard. Read replicas serve seat map browsing. The hot write path (holds + purchases) stays on the primary shard for the event.
  • Distributed locking vs DB locking? Prefer database-level locking (SELECT FOR UPDATE or optimistic locking with status check) over Redis distributed locks (Redlock) — DB transactions guarantee consistency even on failure, while distributed locks have edge cases around clock skew and lock release on crash.

{
“@context”: “https://schema.org”,
“@type”: “FAQPage”,
“mainEntity”: [
{
“@type”: “Question”,
“name”: “How do you prevent double-booking in a seat reservation system?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Double-booking is prevented using database-level constraints combined with optimistic or pessimistic locking. The recommended approach is an optimistic UPDATE: “UPDATE seats SET status=’held’, held_by=? WHERE id=? AND status=’available'” u2014 this succeeds only if the seat is still available at update time, and returns 0 rows if another transaction already claimed it. This avoids long-held locks while guaranteeing no two transactions can both succeed on the same seat. For high-contention scenarios (popular seats), a distributed queue can serialize hold attempts for the same seat ID. Database unique constraints on (seat_id, status=’sold’) provide a backstop guarantee, but the UPDATE pattern catches conflicts at the application level with better UX.”
}
},
{
“@type”: “Question”,
“name”: “How do you handle 500,000 concurrent users during a concert ticket onsale?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Handle flash sale traffic with a virtual queue architecture: before the onsale time, users register and are assigned a random queue position (randomization prevents bots from gaining advantage via exact onsale-time requests). At onsale time, the queue service admits users at a controlled rate matching backend capacity. Each admitted user receives a signed time-limited session token (JWT with event_id, valid_until). Only users with valid tokens can access the purchase flow. This caps concurrent active purchase sessions at a sustainable level (e.g., 10,000 simultaneous checkouts) regardless of how many users hit the site simultaneously. Combined with aggressive caching for the event browsing pages (CDN + Redis) and DB sharding by event_id, the system handles 500K concurrent users without degradation.”
}
},
{
“@type”: “Question”,
“name”: “How do you display real-time seat availability to thousands of users simultaneously?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Real-time seat availability uses Server-Sent Events (SSE): clients open a persistent HTTP connection to a streaming endpoint, and the server pushes seat status changes as they occur. When a seat transitions (available u2192 held u2192 sold), the application publishes to Redis Pub/Sub, and a broadcaster service connected to that channel pushes the update to all clients watching that event. SSE is preferred over polling (stale data, unnecessary load) and WebSocket (bidirectional overkill for display-only). For scale, use a Redis Cluster for Pub/Sub fan-out and horizontally scale the SSE broadcaster servers behind a load balancer u2014 each broadcaster handles 10,000-50,000 concurrent SSE connections.”
}
}
]
}

  • Coinbase Interview Guide
  • Shopify Interview Guide
  • DoorDash Interview Guide
  • Stripe Interview Guide
  • Airbnb Interview Guide
  • Uber Interview Guide
  • Companies That Ask This

    Scroll to Top