A ticket booking system like Ticketmaster must handle concurrent seat selection, hold expiry, payment confirmation, and flash sale traffic spikes. The core engineering challenge is preventing two users from booking the same seat while keeping the system fast enough to handle millions of users flooding in simultaneously.
Requirements
- Browse events and view a real-time seat map.
- Select one or more seats and hold them for 10 minutes while the user completes checkout.
- Complete payment to confirm booking, or let the hold expire and release seats back to inventory.
- Handle waitlists for sold-out events.
- Survive flash sale traffic spikes (millions of users at the same moment tickets go on sale).
Seat States and Transitions
Each seat has a simple state machine:
AVAILABLE --> HELD (hold created, 10 min TTL)
HELD --> BOOKED (payment confirmed)
HELD --> AVAILABLE (hold expired or user cancelled)
BOOKED --> AVAILABLE (booking cancelled, if policy allows)
The database must enforce these transitions atomically. No seat should ever be in an ambiguous state.
The Concurrent Booking Problem
Two users load the seat map at the same time. Both see seat A12 as available. Both click it. Without concurrency control, both could “book” the same seat. Solutions: pessimistic locking or optimistic locking.
Pessimistic Locking
Lock the seat row when a user selects it. Other transactions block until the lock is released.
BEGIN;
SELECT * FROM seats WHERE id = 42 FOR UPDATE; -- acquires row lock
-- if seat is AVAILABLE, proceed
UPDATE seats SET status = 'HELD', holder_id = :user_id, expires_at = NOW() + INTERVAL '10 minutes'
WHERE id = 42;
COMMIT;
Simple and correct but serializes access. Under high concurrency, lock contention causes timeouts and poor user experience. Acceptable for low-traffic events; bad for flash sales.
Optimistic Locking
Add a version column. Read the current version, attempt an update that checks the version, check how many rows were affected.
-- Read phase
SELECT id, status, version FROM seats WHERE id = 42;
-- Returns: status=AVAILABLE, version=7
-- Write phase (atomic)
UPDATE seats
SET status = 'HELD',
holder_id = :user_id,
expires_at = NOW() + INTERVAL '10 minutes',
version = version + 1
WHERE id = 42
AND status = 'AVAILABLE'
AND version = 7;
-- Check rows_affected:
-- 1 row updated = success, seat is yours
-- 0 rows updated = someone else got it first, show error
No blocking. High throughput. Users who lose the race get an immediate error and can try another seat. Better for high-concurrency scenarios.
Redis-Based Seat Lock
For maximum throughput, use Redis as the locking layer and avoid hitting the database on every seat selection:
# Attempt to lock seat. NX = only set if not exists. EX = expire in 600 seconds.
SET seat:{event_id}:{seat_id} {user_id} NX EX 600
# Returns OK if lock acquired, nil if seat already held
Flow:
- User selects seat – app calls
SET seat:101:A12 user_456 NX EX 600. - If
OK: seat is locked. Show checkout page. Start 10-minute countdown. - If
nil: seat is taken. Show “seat unavailable” immediately. - On successful payment: write
BOOKEDstatus to database, delete Redis key. - On hold expiry: Redis TTL fires automatically. Seat becomes available again with no cleanup needed.
Redis handles hundreds of thousands of SET operations per second. The database only gets written on confirmed bookings, not on every hold attempt.
Database Schema
CREATE TABLE events (
id BIGINT PRIMARY KEY,
name VARCHAR(255),
venue_id BIGINT,
event_time TIMESTAMPTZ,
status VARCHAR(20) DEFAULT 'ACTIVE'
);
CREATE TABLE seats (
id BIGINT PRIMARY KEY,
event_id BIGINT REFERENCES events(id),
section VARCHAR(20),
row VARCHAR(10),
number INT,
status VARCHAR(20) DEFAULT 'AVAILABLE', -- AVAILABLE, HELD, BOOKED
holder_id BIGINT,
expires_at TIMESTAMPTZ,
booking_id BIGINT,
version INT DEFAULT 0,
CONSTRAINT uq_seat UNIQUE (event_id, section, row, number)
);
CREATE TABLE bookings (
id BIGINT PRIMARY KEY,
user_id BIGINT,
event_id BIGINT,
seat_ids BIGINT[],
status VARCHAR(20), -- PENDING, CONFIRMED, CANCELLED
total_amount NUMERIC(10,2),
idempotency_key VARCHAR(128) UNIQUE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_seats_event_status ON seats(event_id, status);
Payment Integration
On successful payment callback, atomically confirm the booking in a single database transaction:
BEGIN;
-- 1. Verify seats are still held by this user (not expired)
SELECT id FROM seats
WHERE id = ANY(:seat_ids)
AND status = 'HELD'
AND holder_id = :user_id
AND expires_at > NOW()
FOR UPDATE;
-- 2. Confirm seats
UPDATE seats SET status = 'BOOKED', booking_id = :booking_id
WHERE id = ANY(:seat_ids);
-- 3. Record booking
INSERT INTO bookings (id, user_id, event_id, seat_ids, status, total_amount, idempotency_key)
VALUES (:booking_id, :user_id, :event_id, :seat_ids, 'CONFIRMED', :amount, :idempotency_key)
ON CONFLICT (idempotency_key) DO NOTHING;
COMMIT;
The idempotency_key (typically the payment provider’s charge ID) prevents double-booking if the payment callback is delivered twice.
Waitlist
For sold-out events:
- User joins waitlist – stored as an ordered queue (Redis sorted set with join_time as score, or a DB table).
- When a seat is released (hold expires or booking cancelled), a background job dequeues the first N waitlisted users.
- Each dequeued user receives a time-limited purchase link (valid for 15-30 minutes).
- If they do not complete purchase, the next user in queue is notified.
# Redis waitlist for event 101
ZADD waitlist:101 {timestamp} {user_id} # enqueue
# When seat released:
user_ids = ZRANGE waitlist:101 0 2 # get next 3 users
ZREM waitlist:101 *user_ids # dequeue them
# Send each user a time-limited purchase link
High-Traffic Events and Flash Sales
When a popular artist goes on sale, millions of users hit the booking page simultaneously. Strategies:
- Virtual waiting room: redirect all incoming traffic to a queue page before tickets go on sale. Use a token bucket or queue system to meter users into the booking flow. Users see their position in queue. Only a controlled number of users access the actual seat map at any time.
- Pre-queue: open the waiting room 30 minutes before sale time. Randomize queue positions to prevent bots from timing their arrival precisely.
- Cache the seat map: serve the initial seat map from a CDN or Redis cache. Refresh availability on a 5-10 second polling interval rather than per-request. Users see slight staleness but the DB is not overwhelmed.
- Rate limit seat holds per user per event: prevent a single user (or bot) from holding all remaining seats simultaneously.
- Reserve capacity: keep a percentage of seats back from the initial sale for secondary releases, reducing the peak spike volume.
- Separate read and write paths: seat availability reads go to read replicas or Redis; seat hold writes go to the primary DB.
Summary – Key Design Decisions
| Decision | Recommendation | Reason |
|---|---|---|
| Hold locking mechanism | Redis SET NX EX | Fast, atomic, auto-expiring |
| Concurrent booking prevention | Optimistic locking (version column) | No blocking, high throughput |
| Payment idempotency | Idempotency key (charge ID) | Prevents double booking on retry |
| Flash sale traffic | Virtual waiting room | Controls load, fair access |
| Seat map reads | Cache with short TTL (5-10s) | DB cannot serve millions of reads |
| Waitlist | Redis sorted set ordered by join time | Fast, naturally ordered |
Airbnb system design covers booking and reservation systems. See design patterns for Airbnb interview: reservation and booking system design.
Stripe system design covers payment processing for ticket booking. See patterns for Stripe interview: payment and ticket booking design.
Twitter handles high-traffic on-sale events. See system design patterns for Twitter/X interview: high-traffic event and booking design.