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 -> payAPI
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 : CDCBooking 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
bookedin 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.