Courses › System Design › Question Breakdowns

Design Ticketmaster

Seat holds without double-booking, expiring reservations, fair waiting lines and surge traffic.

Ticketmaster is an event ticketing platform: users browse events, pick seats on a map and buy tickets. It's a favorite interview question because it combines three hard problems: contention (never sell a seat twice), surge traffic (millions of users at on-sale time) and search.

Requirements

Functional

  • Users can view an event, including a seat map with availability
  • Users can search for events by keyword, location, date
  • Users can book tickets for specific seats

Out of scope: adding events (admin), dynamic pricing, transfers and resale.

Non-functional

  • Strong consistency for booking — no seat may be sold twice
  • High availability for browsing and search
  • Handle massive spikes — 10M users arriving for a popular on-sale
  • Low-latency search (< 500ms)
  • Read-heavy: ~100:1 views to bookings
  • Fair access during an on-sale: people get in roughly in the order they arrived, and bots don't get an edge
  • All or nothing: a booking gets every requested seat or none, up to a per-account limit the organizer sets (often 4–8 for a big tour)

[!TIP] Back-of-the-envelope Storage is small. Take a national theater chain: 400 theaters × 12 screens × 250 seats × 5 showtimes/day is 6M seat-showtimes a day; at ~80 bytes each that's ~0.5 GB/day, under 1 TB over five years. The traffic is the hard part: billions of page views a month and on-sale spikes that are thousands of times the average.

Core Entities

  • Event — name, date, venue, performer
  • Venue — location, seat layout
  • Ticket — event, seat, price, status (available / reserved / booked)
  • Booking — user, tickets, total, status

A movie theater chain needs one more level, because the same physical seat is sold again for every screening. A showtime is one screening of a film on one screen, and availability is tracked per showtime seat, never on the physical seat:

theater = store "Theater\n(address, city)" @0,0
screen = store "Screen" @1,0
seat = store "Seat\n(row, number)" @2,0
film = store "Film" @0,1
showtime = store "Showtime" @1,1
st = store "Showtime Seat\n(status, price)" @2,1
booking = store "Booking" @3,1
user = store "User" @3,2
pay = store "Payment" @2,2
theater -> screen : 1:N
screen -> seat : 1:N
film -> showtime : 1:N
showtime -> screen
showtime -> st : 1:N
st -> seat
booking -> st : holds
user -> booking : 1:N
booking -> pay

API

GET  /v1/events/{eventId}                  → event, venue, ticket availability
GET  /v1/events/search?q=&city=&date=      → events[]
POST /v1/bookings/reserve  { ticketIds }   → { bookingId, expiresAt }
POST /v1/bookings/confirm  { bookingId, paymentDetails }

When a reserve fails, return which seats were taken, plus the best still-free block of the same size in that section, so the UI can offer an alternative in one tap instead of sending the user back to the map.

High-Level Design

user = client "Client" @0,1
gw = service "API Gateway" @1,1
event = service "Event Service" @2,0
search = service "Search Service" @2,1
booking = service "Booking Service" @2,2
pg = store "Postgres\n(events, tickets)" @3,0.5
es = store "Elasticsearch" @3,1.5
pay = service "Payment Processor" @3,2.5
user -> gw
gw -> event, search, booking
event -> pg
booking -> pg
search -> es
booking -> pay
pg --> es : CDC

Booking is a two-step process:

  • Reserve — the user selects seats; the tickets are held for ~10 minutes while they enter payment details.
  • Confirm — payment succeeds (confirmed via the payment provider's webhook), and tickets become booked.

Deep Dives

1. Reserving seats without double-booking

Bad: a long-running database transaction spanning the checkout. It holds locks for minutes and won't survive a closed browser tab.

Good — status + expiry in the database. Add reserved_until to tickets and reserve with a conditional update:

UPDATE tickets
SET status = 'reserved', reserved_by = :user, reserved_until = now() + interval '10 minutes'
WHERE id = ANY(:ticketIds)
  AND (status = 'available' OR (status = 'reserved' AND reserved_until < now()));
-- If fewer rows were updated than requested, roll back: someone beat you to a seat.

Expired holds automatically count as available — no cleanup job is needed for correctness.

Also good — a serializable transaction. Read the requested seats and update them in one transaction at the strictest isolation level, so concurrent bookings of the same seat can't both succeed:

BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT seat_id FROM showtime_seats
WHERE showtime_id = :showtime AND seat_id = ANY(:seats) AND status = 'available'
FOR UPDATE;
-- every requested seat came back? hold them all; otherwise ROLLBACK and say which seats are gone
UPDATE showtime_seats SET status = 'reserved', booking_id = :booking
WHERE showtime_id = :showtime AND seat_id = ANY(:seats);
INSERT INTO bookings (id, showtime_id, user_id, status, expires_at)
VALUES (:booking, :showtime, :user, 'reserved', now() + interval '10 minutes');
COMMIT;

The transaction is short (milliseconds): it only records the hold. The minutes the user spends paying happen outside it.

Also good — distributed lock with TTL. Hold each seat in Redis (SET hold:{ticketId} {user} NX EX 600). The database only records final bookings. Fast, and the TTL frees abandoned holds — but now Redis and the database must agree, so the confirm step must re-check in the database.

2. Handling the on-sale surge

When 10M users arrive at once:

  • Virtual waiting room — users join a queue (e.g. a Redis sorted set by arrival time) and are admitted in batches as capacity allows. Admitted users get a short-lived token that the booking service checks. This protects the booking service and makes the experience fair.
  • Cache event and venue data aggressively — it's identical for everyone.
  • Seat availability changes quickly; push updates to clients with SSE rather than having millions poll.

3. Releasing seats and keeping the seat map live

Expired holds need no timer: the reserved_until condition above already treats them as available. What's missing is telling people:

  • Every reserve, booking and expiry publishes a small event (event 812: seats 14–17 in section 104 changed) to a per-event pub/sub channel. A periodic sweep marks expired holds and publishes them too, so seats freed by expiry appear without anyone touching them.
  • Users looking at the seat map receive these events over SSE and repaint those seats. Nobody re-downloads a 50,000-seat map to learn that four seats changed.
  • The countdown in checkout is driven by the server's expiresAt, not the device clock: the client computes the remaining time from the server time sent with the response, so a phone with a wrong clock still shows the right deadline.

Partitioning: shard bookings by event ID. A 40-date stadium tour is 40 separate events, so its load spreads naturally. The one hot key is a single huge on-sale, and the waiting room in section 2 exists to cap how many people reach it at once.

4. Fast, flexible search

LIKE '%taylor%' on Postgres doesn't scale. Index events in Elasticsearch (synced from Postgres via CDC) for full-text, location and date filtering. Cache popular queries; put a CDN in front of search results that aren't personalized.

5. Payment

  • Call the payment processor with an idempotency key tied to the booking.
  • Confirm via webhook; on success, mark tickets booked in the same transaction as the booking.
  • If payment completes after the hold expired and the seat was taken, refund automatically — rare, but it must be handled.

What's expected at each level

  • Mid-level: a working two-phase booking flow and a correct way to prevent double-booking.
  • Senior: compares reservation strategies, adds search via Elasticsearch, addresses the surge with caching and real-time seat updates.
  • Staff: designs the virtual queue, discusses fairness and bot mitigation, live seat-map updates, failure handling across payment and booking, and multi-region concerns.