Designing a ticketing system like Ticketmaster or a seat reservation system for movies/concerts tests your understanding of concurrency, distributed locking, and handling flash sale traffic spikes. It’s a common system design interview question at companies including Atlassian, DoorDash, and Airbnb.
Core Requirements
Functional: Browse events, view seat map with real-time availability, select seats (held for 10 minutes), complete purchase, receive tickets, cancel/transfer tickets.
Non-functional: Handle 100K concurrent users during popular event onsales, prevent double-booking (exactly-once seat assignment), < 200ms API response times, 99.99% availability.
The Core Problem: Preventing Double-Booking
Naive approach (WRONG — race condition):
1. Client A: SELECT status FROM seats WHERE id=5 → available
2. Client B: SELECT status FROM seats WHERE id=5 → available
3. Client A: UPDATE seats SET status='sold' WHERE id=5
4. Client B: UPDATE seats SET status='sold' WHERE id=5
→ Both succeed! Seat 5 is double-booked.
Fix 1: Pessimistic locking (SELECT FOR UPDATE)
BEGIN;
SELECT * FROM seats WHERE id=5 FOR UPDATE; -- acquires row lock
-- only one transaction can hold this lock
UPDATE seats SET status='reserved', held_by='user_A' WHERE id=5;
COMMIT; -- lock released
Seat State Machine
Available → Held (10-min TTL) → Purchased
↓ (timeout or abandon)
Available
Implementation:
seats table:
id BIGINT PRIMARY KEY
event_id BIGINT
section VARCHAR
row CHAR
number INT
status ENUM('available', 'held', 'sold')
held_by UUID -- user session ID
held_until TIMESTAMPTZ -- expiry for held seats
order_id UUID -- once sold
Hold a seat:
UPDATE seats
SET status='held', held_by=?, held_until=NOW()+INTERVAL '10 min'
WHERE id=? AND status='available' -- ← optimistic: only succeeds if available
RETURNING id;
-- 0 rows updated = seat was taken, return error to client
Expire held seats (background job every 30s):
UPDATE seats
SET status='available', held_by=NULL, held_until=NULL
WHERE status='held' AND held_until < NOW();
Flash Sale Architecture: Handling Traffic Spikes
Problem: Taylor Swift onsale → 500K concurrent users hit "Buy" at 10:00 AM
Solution: Virtual Queue
User flow:
1. User registers for queue (before onsale)
2. At onsale time, queue opens → users assigned random positions
3. Users served in queue order with rate limiting
4. Each user gets a time-boxed session (30 min) to complete purchase
Architecture:
Pre-queue: SQS FIFO queue or Redis sorted set
Zset score = random float (randomizes order)
Queue service:
→ Polls queue, admits N users per minute (N = capacity)
→ Issues signed JWT token to admitted user: {user_id, valid_until, event_id}
→ User's requests must include this token
Throttle: Nginx rate limit → 1000 req/s per event_id globally
Token bucket per session prevents multiple purchases
Seat Map: Real-Time Availability
Availability display options:
Option A: REST polling (simple)
Client polls GET /events/{id}/seats every 5 seconds
Server queries Redis bitmap or seats table
Pros: simple; Cons: stale data, unnecessary load
Option B: Server-Sent Events (SSE) — recommended
Client: EventSource('/api/events/{id}/availability')
Server: pushes seat status changes as they happen
Changes: seat transitions to held/sold → publish to Redis Pub/Sub
→ SSE broadcaster → all subscribed clients
Pros: real-time, unidirectional, auto-reconnect
Cons: server holds connection per client (use connection pooling)
Option C: WebSocket
Bidirectional — overkill for display-only availability
Use for collaborative seat selection (multiple users see each other's cursors)
Redis bitmap for fast availability check:
Key: avail:{event_id}
Bit N = 1 if seat N available, 0 if held/sold
BITCOUNT key → total available seats in O(1)
SETBIT / GETBIT for individual seat check
Database Design
CREATE TABLE events (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
venue_id BIGINT,
starts_at TIMESTAMPTZ,
onsale_at TIMESTAMPTZ,
status TEXT DEFAULT 'upcoming'
);
CREATE TABLE seats (
id BIGSERIAL PRIMARY KEY,
event_id BIGINT REFERENCES events(id),
section TEXT,
row_label TEXT,
seat_num INT,
price_cents INT,
status TEXT DEFAULT 'available',
held_by UUID,
held_until TIMESTAMPTZ,
order_id UUID,
INDEX (event_id, status),
INDEX (held_until) WHERE status = 'held' -- partial index
);
CREATE TABLE orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id BIGINT,
event_id BIGINT,
status TEXT DEFAULT 'pending',
total_cents INT,
created_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ -- 10-min payment window
);
CREATE TABLE order_seats (
order_id UUID REFERENCES orders(id),
seat_id BIGINT REFERENCES seats(id),
PRIMARY KEY (order_id, seat_id)
);
Purchase Flow with Idempotency
Checkout flow:
1. User selects seats → POST /holds {seat_ids: [5, 6], session_id}
→ DB: UPDATE seats SET status='held' WHERE id IN (5,6) AND status='available'
→ If any seat already taken → 409 Conflict with specific seat IDs
→ Cache: set hold TTL in Redis for expiry tracking
2. User enters payment → POST /orders {hold_id, payment_method_nonce}
→ Create order with status='pending', expires_at=NOW()+10min
→ Call payment provider (Stripe) with idempotency_key=order_id
(idempotency_key prevents double-charge on retry)
→ On payment success:
BEGIN;
UPDATE orders SET status='confirmed' WHERE id=? AND status='pending';
UPDATE seats SET status='sold', order_id=? WHERE id IN (5,6) AND held_by=session_id;
COMMIT;
→ Generate ticket QR codes (signed JWTs with seat + order info)
→ Send confirmation email async (SQS → Lambda/worker)
3. On payment failure:
UPDATE seats SET status='available', held_by=NULL WHERE id IN (5,6)
DELETE FROM orders WHERE id=?
Capacity Estimation
- Concert venue: 50,000 seats per event
- Peak onsale: 500K concurrent users for 5 minutes
- Read traffic: 500K × 10 req/min = 83K req/sec (browsing, polling)
- Write traffic: ~1K purchase attempts/sec at peak (10-min hold window)
- DB: PostgreSQL handles ~10K TPS on seats table (row-level locking)
- Redis: handles 1M ops/sec — seat availability cache is critical path
Interview Discussion Points
- Why not Redis for seat holds instead of DB? Redis doesn’t support ACID transactions across seat + order tables. The final sold state must be durable — use Redis as a cache/pubsub layer, PostgreSQL for the source of truth.
- How to handle payment provider timeout? Use async confirmation: POST /orders returns “pending” immediately. Stripe sends webhook on completion → update order status → send ticket. Idempotency key on Stripe charge prevents double-charging on retry.
- How to scale DB for 500K concurrent users? Shard by event_id — each major event gets its own DB shard. Read replicas serve seat map browsing. The hot write path (holds + purchases) stays on the primary shard for the event.
- Distributed locking vs DB locking? Prefer database-level locking (SELECT FOR UPDATE or optimistic locking with status check) over Redis distributed locks (Redlock) — DB transactions guarantee consistency even on failure, while distributed locks have edge cases around clock skew and lock release on crash.
{
“@context”: “https://schema.org”,
“@type”: “FAQPage”,
“mainEntity”: [
{
“@type”: “Question”,
“name”: “How do you prevent double-booking in a seat reservation system?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Double-booking is prevented using database-level constraints combined with optimistic or pessimistic locking. The recommended approach is an optimistic UPDATE: “UPDATE seats SET status=’held’, held_by=? WHERE id=? AND status=’available'” u2014 this succeeds only if the seat is still available at update time, and returns 0 rows if another transaction already claimed it. This avoids long-held locks while guaranteeing no two transactions can both succeed on the same seat. For high-contention scenarios (popular seats), a distributed queue can serialize hold attempts for the same seat ID. Database unique constraints on (seat_id, status=’sold’) provide a backstop guarantee, but the UPDATE pattern catches conflicts at the application level with better UX.”
}
},
{
“@type”: “Question”,
“name”: “How do you handle 500,000 concurrent users during a concert ticket onsale?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Handle flash sale traffic with a virtual queue architecture: before the onsale time, users register and are assigned a random queue position (randomization prevents bots from gaining advantage via exact onsale-time requests). At onsale time, the queue service admits users at a controlled rate matching backend capacity. Each admitted user receives a signed time-limited session token (JWT with event_id, valid_until). Only users with valid tokens can access the purchase flow. This caps concurrent active purchase sessions at a sustainable level (e.g., 10,000 simultaneous checkouts) regardless of how many users hit the site simultaneously. Combined with aggressive caching for the event browsing pages (CDN + Redis) and DB sharding by event_id, the system handles 500K concurrent users without degradation.”
}
},
{
“@type”: “Question”,
“name”: “How do you display real-time seat availability to thousands of users simultaneously?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Real-time seat availability uses Server-Sent Events (SSE): clients open a persistent HTTP connection to a streaming endpoint, and the server pushes seat status changes as they occur. When a seat transitions (available u2192 held u2192 sold), the application publishes to Redis Pub/Sub, and a broadcaster service connected to that channel pushes the update to all clients watching that event. SSE is preferred over polling (stale data, unnecessary load) and WebSocket (bidirectional overkill for display-only). For scale, use a Redis Cluster for Pub/Sub fan-out and horizontally scale the SSE broadcaster servers behind a load balancer u2014 each broadcaster handles 10,000-50,000 concurrent SSE connections.”
}
}
]
}