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 |
{“@context”:”https://schema.org”,”@type”:”FAQPage”,”mainEntity”:[{“@type”:”Question”,”name”:”How do you prevent two users from booking the same seat simultaneously?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Two approaches: (1) Pessimistic locking: use SELECT … FOR UPDATE on the seat row within a transaction. This serializes concurrent bookings for the same seat. Simple but can cause contention and deadlocks at scale. (2) Optimistic locking: add a version column. The update uses a conditional: UPDATE seats SET status='HELD', holder_id=X, version=version+1 WHERE id=Y AND status='AVAILABLE' AND version=Z. If 0 rows updated, another user grabbed the seat first – return a conflict error and let the user pick another seat. Optimistic locking has better throughput since it only serializes at commit time, not during the read. For extreme scale: use Redis SET NX for the hot lock path (Redis handles the race condition atomically), then confirm in the DB asynchronously.”}},{“@type”:”Question”,”name”:”How does Redis-based seat locking work for a ticket booking system?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”SET seat:{event_id}:{seat_id} {user_id} NX EX 600 – atomic command: set the key only if it does not exist (NX) with a 10-minute expiry (EX 600). If the command returns OK: seat locked for this user. If it returns nil: another user holds the seat. On timeout expiry, Redis automatically releases the key, making the seat available again without a cleanup job. When the user completes payment: delete the Redis key (using a Lua script to ensure only the key holder can delete: compare value before deleting) and write BOOKED status to the DB atomically. This pattern handles the hold without a background expiry job. Limitation: Redis is not transactional with the DB, so you need idempotency in the payment flow to handle the case where Redis is deleted but the DB write fails.”}},{“@type”:”Question”,”name”:”How do you design a seat map for high-traffic events?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Seat map data (venue layout, seat coordinates, categories) is mostly static – cache aggressively. Seat availability changes per booking. Two-layer approach: (1) Static layer: cache the venue layout in CDN or application cache. Rarely changes. (2) Dynamic layer: availability overlay – which seats are AVAILABLE, HELD, or BOOKED. This changes rapidly during on-sale events. Serve the static seat map from CDN. Separately fetch the availability overlay from a fast store (Redis bitmap or hash: O(1) lookup per seat). For on-sale events with millions of concurrent viewers: pre-load the availability overlay into the CDN edge, invalidate only changed seats (event-based invalidation). Avoid fetching real-time availability for every user on every page load – use a polling approach every 30 seconds.”}},{“@type”:”Question”,”name”:”How do you handle the waitlist for a fully-booked event?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”When a seat becomes available (hold expiry or cancellation): (1) Query the waitlist for that event (a Redis sorted set ordered by join_time: ZPOPMIN waitlist:{event_id} 3 to get the first 3 waitlisted users). (2) Send each a time-limited purchase notification with a JWT token encoding {event_id, seat_id, user_id, expires_at}. The token is valid for 2-5 minutes. (3) The first user to click the link and complete payment claims the seat using the normal booking flow. (4) The other notified users receive a "seat was claimed" notification and are returned to the waitlist position. Why notify 3 instead of 1: some users may not be actively checking their phone. Notifying a small batch increases conversion speed without causing confusion.”}},{“@type”:”Question”,”name”:”How do you handle ticket booking system load during on-sale events?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Popular events can generate millions of concurrent requests when tickets go on sale. Five strategies: (1) Virtual waiting room: queue all users who arrive before on-sale time. At on-sale time, randomly assign positions in a controlled queue and release users in batches. (2) Pre-registration: require email registration 24 hours before. On sale day, issue batch-specific entry tokens. (3) Read-only pre-cache: serve event and seat map pages from CDN during the peak; only the booking API hits the application servers. (4) Capacity metering: limit concurrent users on the booking page. Users wait in a visible queue before entering the booking flow. (5) Inventory reservation per batch: allocate a fixed pool of seats to each batch of users to prevent over-contention on the global inventory.”}}]}
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.