System Design Interview: Ticketing System (Ticketmaster / Concert Tickets)

Ticketing systems for concerts and sports events are famous for their demand spikes: a Taylor Swift concert sells 50,000 tickets in minutes, with millions of users simultaneously competing for seats. The design challenge is preventing overselling while maintaining fairness and handling extreme traffic bursts.

Unique Challenges

  • Flash sale traffic: 0 → millions of requests in seconds when sales open
  • Seat contention: multiple users simultaneously want the same seat
  • No overselling: never sell the same seat twice — a hard constraint
  • Fairness: first come, first served — do not let bots or wealthy buyers exploit the system
  • Abandonment: user holds seats during checkout, then does not pay

Functional Requirements

  • Browse events and venue seating maps
  • Select seats and hold them temporarily during checkout (5-10 minutes)
  • Complete purchase with payment — convert hold to confirmed ticket
  • Release held seats back to inventory if payment not completed
  • Resale/transfer of tickets

Database Schema

-- Events
CREATE TABLE events (
    id          BIGINT PRIMARY KEY,
    venue_id    BIGINT NOT NULL,
    name        VARCHAR(255) NOT NULL,
    event_date  TIMESTAMP NOT NULL,
    sale_opens  TIMESTAMP NOT NULL,
    status      ENUM("upcoming","on_sale","sold_out","cancelled") DEFAULT "upcoming"
);

-- Seats (pre-loaded for every seat in the venue)
CREATE TABLE seats (
    id          BIGINT PRIMARY KEY,
    event_id    BIGINT REFERENCES events(id),
    section     VARCHAR(50),
    row_label   VARCHAR(10),
    seat_number INT,
    seat_type   ENUM("standard","vip","accessible"),
    price_cents INT NOT NULL,
    status      ENUM("available","held","sold") DEFAULT "available",
    hold_expires TIMESTAMP,   -- when hold expires (NULL if available/sold)
    order_id    BIGINT        -- set when sold
);

CREATE INDEX idx_seats_event_status ON seats(event_id, status);

Seat Hold: Preventing Overselling

The core challenge: two users simultaneously select the same seat. Both see it as available. Without coordination, both could purchase it.

Approach 1: Database Optimistic Lock

-- Atomic seat hold using database UPDATE with WHERE clause
-- Only one transaction will succeed; the other sees 0 rows affected

UPDATE seats
SET status = "held",
    hold_expires = NOW() + INTERVAL 600 SECONDS,  -- 10-minute hold
    holder_session = "session_abc123"
WHERE id = 12345
  AND status = "available";  -- condition ensures atomicity

-- Returns: rows_affected
-- rows_affected = 1 → seat is yours (hold successful)
-- rows_affected = 0 → seat was taken (race condition — someone else got it)

Approach 2: Redis Distributed Lock (Higher Throughput)

def hold_seat(event_id: int, seat_id: int, user_session: str) -> bool:
    lock_key = f"seat:lock:{event_id}:{seat_id}"

    # SET NX (set if not exists) with 600-second TTL
    acquired = redis.set(lock_key, user_session, nx=True, ex=600)

    if not acquired:
        return False  # seat already held or sold

    # Update DB asynchronously
    sql = "UPDATE seats SET status = 'held', hold_expires = NOW() + INTERVAL 600 SECONDS WHERE id = %s AND status = 'available'"
    db.execute(sql, seat_id)

    return True

Virtual Waiting Room for Flash Sales

Without a queue, all users hammer the system simultaneously and most get errors. A virtual waiting room is a better user experience:

Architecture:
  1. Sale opens → all users directed to waiting room (not ticket selection)
  2. Waiting room assigns each user a random position in a virtual queue
  3. Users enter the seat selection page in batches as capacity allows
  4. System controls inflow: release 1,000 users every 30 seconds
  5. User sees estimated wait time based on queue position

Implementation:
  Redis sorted set: "queue:{event_id}"
  Score: random float in [0,1] — determines fair random order
  Member: user_session_id

  # Add user to queue
  ZADD queue:{event_id} {random()} {session_id}

  # Get next 1000 users to let in
  ZRANGE queue:{event_id} 0 999 BYSCORE  # returns session IDs

  # Remove processed users
  ZREM queue:{event_id} {session_id1} {session_id2} ...

  # Wait estimate for user at rank R with 1000 let in per 30s:
  estimated_wait = (rank / 1000) * 30  # seconds

Hold Expiry and Seat Release

-- Scheduled job: release expired holds every minute
UPDATE seats
SET status = "available",
    hold_expires = NULL,
    holder_session = NULL
WHERE status = "held"
  AND hold_expires < NOW();

-- For Redis approach: TTL auto-expires the lock
-- But also need DB update to put seats back to available

-- Alternatively: expiry job reads the DB, uses FOR UPDATE to prevent races:
BEGIN;
SELECT id FROM seats
WHERE status = "held" AND hold_expires < NOW()
FOR UPDATE SKIP LOCKED;  -- only process rows not held by another transaction

UPDATE seats SET status = "available" WHERE id IN (...);
COMMIT;

Purchase Flow

User selects seats → seats held in Redis + DB (10 min window)
         ↓
User fills in payment details (within 10 min)
         ↓
[Payment Service]
  POST /v1/payments with idempotency_key = "ticket_purchase:{hold_id}"
         ↓ Payment succeeds
[Order Service]
  1. BEGIN transaction
  2. Verify seats still held by this session
  3. UPDATE seats SET status = "sold", order_id = {order_id}
  4. INSERT order record
  5. DELETE Redis hold lock
  6. COMMIT
         ↓
[Ticket Service]
  - Generate ticket with unique barcode (UUID + HMAC)
  - Send confirmation email with ticket PDF
  - Remove seats from availability index

Scaling for Flash Sales

  • Read scaling: seating map, event info — served from CDN + Redis cache. This is 95% of traffic
  • Write scaling: seat holds — bottleneck. Shard Redis by event_id. DB holds use FOR UPDATE which serialize per-seat but different seats are independent
  • Bot prevention: CAPTCHA before entering queue, velocity limits per IP, purchase limits per account (e.g., max 8 tickets), device fingerprinting
  • Inventory management: Reserve 10% of inventory for day-of sale, accessibility, and artist holds. Release in batches to prevent sell-outs in the first 10 seconds

Interview Discussion Points

  • How do you prevent two users from buying the same seat? Atomic DB UPDATE with WHERE status = “available” — only one transaction can change a row from available to held
  • What happens if payment fails after hold? Hold expires after 10 minutes; seat released back to available pool. User shown error and offered chance to retry (refresh hold timer)
  • How do you handle bot scalpers? CAPTCHA, purchase limits per account, IP rate limiting, behavioral analysis (too fast = bot), delayed purchase confirmation for suspicious patterns
  • How do you scale to 1M users when Taylor Swift tickets go on sale? Virtual waiting room reduces concurrent DB write load; read traffic from CDN; horizontal scale of app servers; Redis cluster for hold coordination

Frequently Asked Questions

How do you prevent two users from buying the same concert ticket?

The key is atomic seat reservation using a database UPDATE with a conditional WHERE clause: UPDATE seats SET status = "held" WHERE id = 12345 AND status = "available". This is a single atomic operation — only one concurrent transaction can change a row from "available" to "held". The operation returns the number of rows affected: 1 means success (you got the seat), 0 means failure (someone else just got it). For higher throughput, use Redis SET NX (set if not exists) with a TTL to atomically claim a distributed lock on the seat ID, then update the database asynchronously. The Redis approach handles more concurrent requests but requires careful fallback if Redis is unavailable.

What is a virtual waiting room and why is it used for ticket sales?

A virtual waiting room is a queue that users enter before they can access the ticket purchase page. Without it, millions of users simultaneously hit the purchase system when sales open, overwhelming the servers and causing most users to see errors. The waiting room assigns each user a random position (using a random score in a Redis sorted set for fairness), then admits users to the purchase flow in batches at a controlled rate (e.g., 1,000 users every 30 seconds). Users see their estimated wait time and queue position. This dramatically reduces peak load on the seat selection and purchase systems, improves fairness (random queue vs whoever has the fastest network), and provides a better user experience (a progress bar beats a crash).

How do you handle seat holds that are never paid for?

When a user selects seats, they are held for 10 minutes (the checkout window). A background job runs every minute and releases holds that have expired: it runs UPDATE seats SET status = "available" WHERE status = "held" AND hold_expires < NOW(). For Redis-based holds, the TTL automatically expires the key — but you must also update the database to mark the seat as available again, either via the same job or a separate Kafka consumer triggered by Redis keyspace expiration events. Released seats immediately re-appear in the available inventory for other buyers. Payment failures mid-checkout also trigger immediate hold release so the seat can be purchased by someone else, rather than waiting for the full 10-minute window to expire.

{
“@context”: “https://schema.org”,
“@type”: “FAQPage”,
“mainEntity”: [
{
“@type”: “Question”,
“name”: “How do you prevent two users from buying the same concert ticket?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “The key is atomic seat reservation using a database UPDATE with a conditional WHERE clause: UPDATE seats SET status = “held” WHERE id = 12345 AND status = “available”. This is a single atomic operation — only one concurrent transaction can change a row from “available” to “held”. The operation returns the number of rows affected: 1 means success (you got the seat), 0 means failure (someone else just got it). For higher throughput, use Redis SET NX (set if not exists) with a TTL to atomically claim a distributed lock on the seat ID, then update the database asynchronously. The Redis approach handles more concurrent requests but requires careful fallback if Redis is unavailable.”
}
},
{
“@type”: “Question”,
“name”: “What is a virtual waiting room and why is it used for ticket sales?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “A virtual waiting room is a queue that users enter before they can access the ticket purchase page. Without it, millions of users simultaneously hit the purchase system when sales open, overwhelming the servers and causing most users to see errors. The waiting room assigns each user a random position (using a random score in a Redis sorted set for fairness), then admits users to the purchase flow in batches at a controlled rate (e.g., 1,000 users every 30 seconds). Users see their estimated wait time and queue position. This dramatically reduces peak load on the seat selection and purchase systems, improves fairness (random queue vs whoever has the fastest network), and provides a better user experience (a progress bar beats a crash).”
}
},
{
“@type”: “Question”,
“name”: “How do you handle seat holds that are never paid for?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “When a user selects seats, they are held for 10 minutes (the checkout window). A background job runs every minute and releases holds that have expired: it runs UPDATE seats SET status = “available” WHERE status = “held” AND hold_expires < NOW(). For Redis-based holds, the TTL automatically expires the key — but you must also update the database to mark the seat as available again, either via the same job or a separate Kafka consumer triggered by Redis keyspace expiration events. Released seats immediately re-appear in the available inventory for other buyers. Payment failures mid-checkout also trigger immediate hold release so the seat can be purchased by someone else, rather than waiting for the full 10-minute window to expire."
}
}
]
}

Companies That Ask This Question

Scroll to Top