Design a Ticket Booking System (Ticketmaster / BookMyShow)
An end-to-end interview-ready walkthrough — from capacity estimation through deep dives on seat state machines, virtual waiting rooms, distributed locking, payment sagas, and bot prevention. Structured to mirror the arc of a 45-minute system design interview.
Requirements
A ticket booking system is deceptively simple on the surface — "user picks a seat, pays, gets a ticket." But at Ticketmaster/BookMyShow scale, you're solving one of the hardest concurrency problems in distributed systems: millions of users competing for a finite, non-fungible resource (a specific seat) within a narrow time window. Unlike e-commerce where you can oversell and backorder, a seat is a unique physical resource that cannot be duplicated. The core tension is that you need strong consistency for a tiny window (seat reservation) while serving millions of concurrent users who expect real-time feedback.
Functional Requirements
Core business logic & features
- 01.Browse Events & VenuesUsers can discover events by category, location, date, and artist. View venue seat maps with section/row/seat layout.
- 02.Search & FilterFull-text search across events, artists, venues. Filter by date range, price range, genre, and availability.
- 03.Reserve & Purchase SeatsUsers select specific seats (or best-available), hold them temporarily, and complete payment within a time window.
- 04.Prevent Double-BookingA seat can only be sold to exactly one user. No overselling, no race conditions, no duplicate reservations.
- 05.Seat Hold with ExpirationHeld seats are locked for 10 minutes. If payment isn't completed, seats are automatically released back to inventory.
- 06.Real-Time Seat AvailabilitySeat map updates in near real-time as seats are held/released. Users see current availability without full page refresh.
Non-Functional
System constraints
Consistency
Zero overselling. A seat must never be sold to two users. Strong consistency on the write path for inventory.
Scale
Handle 10M+ concurrent users during a high-demand launch. 50K seats sold in <2 minutes for top-tier events.
Latency
Seat hold confirmation in <500ms. Seat map refresh in <2s. Payment processing in <5s end-to-end.
Availability
99.99% uptime during active sales. Graceful degradation under extreme load — queue users rather than crash.
🎯 Clarifying questions that change the design
Each of these steers you toward a fundamentally different architecture:
- Assigned seating or general admission? Assigned seating requires per-seat locking (row-level concurrency). General admission is a simple counter decrement (much easier). Most interviews expect assigned seating.
- How long is the hold window? 5 minutes vs 15 minutes changes how aggressively you reclaim seats. Shorter holds = more churn, more contention on re-release.
- Do we support "best available" selection? If yes, the system must atomically find AND reserve the best N seats — a much harder problem than reserving user-selected seats.
- What's the peak concurrent demand? A local theater (1K users) vs Taylor Swift (10M users) requires fundamentally different architectures. The waiting room pattern only matters at extreme scale.
- Multi-region or single-region? If the event is in one city, a single-region deployment with strong consistency is simpler. Global events need careful partition strategy.
- Resale/transfer support? Secondary market adds ownership transfer, price validation, and fraud detection — scope it out unless asked.
In scope vs out of scope
| In Scope | Out of Scope | Why |
|---|---|---|
| Assigned seat selection and reservation | General admission (counter-based) | Assigned seating is the hard problem — GA is a simple atomic decrement |
| Temporary seat holds with TTL expiration | Permanent reservations without payment | Hold-then-pay is the standard two-phase booking flow |
| Payment processing with saga pattern | Full payment gateway implementation | We design the orchestration, not the Stripe/Razorpay internals |
| Virtual waiting room for demand spikes | DDoS protection infrastructure | Waiting room is application-level; DDoS is network-level (Cloudflare) |
| Real-time seat availability updates | Live event streaming / video | Seat map is a state-sync problem, not a media delivery problem |
| Bot prevention and fairness mechanisms | Full anti-fraud / identity verification | We cover queue fairness and CAPTCHA; KYC is a separate system |
| Booking confirmation and e-tickets | Venue entry / QR code scanning | Ticket generation is in scope; physical access control is not |
💡 Interviewer signal
The strongest opening: "I'll focus on the seat reservation pipeline — that's where the distributed systems complexity lives. The core challenge is preventing double-booking under extreme concurrency while maintaining sub-second response times for millions of simultaneous users. I'll cover the waiting room for traffic shaping, Redis-based seat holds with TTL, and the payment saga for atomicity. Event discovery is a read-heavy caching problem I'll address separately." This shows you know where the hard problems are.
Back-of-Envelope Estimation
Ticket booking has a unique traffic shape: 99% of the time it's a low-traffic read-heavy system (browsing events). But during a high-demand on-sale (Taylor Swift, BTS, IPL finals), traffic spikes by 1000× in seconds. The system must be designed for the spike, not the average. Every number you derive should reflect this bimodal distribution — calm browsing vs. absolute chaos at sale time.
Traffic: The on-sale spike
Given (Taylor Swift-scale event):
Venue capacity: 70,000 seats
Registered interest: 14M users (Ticketmaster reported this for Eras Tour)
Users who attempt to buy at sale time: ~3.5M (25% conversion from interest)
Sale window: seats sell out in 2-5 minutes
Peak concurrent users:
3.5M users hitting the system within a 30-second window
→ ~3.5M concurrent connections at peak
Requests per second (peak):
Each user refreshes seat map every 2-3 seconds while browsing
3.5M users × 0.4 requests/sec = 1.4M requests/sec (seat availability reads)
Reservation attempts:
3.5M users competing for 70K seats = 50:1 contention ratio
All 3.5M attempt to reserve within ~60 seconds
→ ~58K reservation attempts/sec (writes)
Normal day (non-spike):
50M monthly active users
Average 5M daily visits (browsing events)
~5K ticket purchases/sec across all events
→ 100× less than peak. The spike IS the design constraint.
Seat inventory operations
The critical path is the seat reservation — an atomic compare-and-swap operation that must be strongly consistent. Unlike messaging systems where the bottleneck is connections, here the bottleneck is on popular seats.
Seat state transitions during peak sale:
Hold attempts: 58K/sec (most will fail — seat already taken)
Successful holds: ~1.2K/sec (70K seats ÷ 60 seconds)
Hold expirations: ~200/sec (users who abandon checkout)
Payment confirms: ~1K/sec (successful purchases)
Seat releases: ~200/sec (payment failures + expirations)
Contention analysis (worst case — front row center):
1 specific seat × 10,000 users wanting it = 10,000 concurrent CAS attempts
Only 1 succeeds. 9,999 get "seat unavailable" and must retry elsewhere.
→ Hot seat problem: some seats have 10,000× more contention than others.
Implication:
→ Per-seat locking is mandatory (not per-event, not per-section)
→ Redis SETNX gives O(1) atomic reservation — no lock waiting
→ Failed attempts must be fast (<10ms) so users can try another seat
→ "Best available" algorithm must avoid hot-seat contention entirely
Storage
Event metadata:
~500K active events at any time (globally)
Average event: 2KB (title, description, dates, venue_id, pricing tiers)
Total: 500K × 2KB = 1 GB (trivial — fits in cache)
Venue and seat maps:
~50K venues globally
Average venue: 30K seats with section/row/seat coordinates
Seat record: 100 bytes (venue_id, section, row, seat_num, coordinates, tier)
Total: 50K venues × 30K seats × 100 bytes = 150 GB
→ Fits in a single Postgres instance. Rarely changes (venue layout is static).
Booking records:
~200M tickets sold/year (Ticketmaster does ~500M globally)
Per booking: 500 bytes (booking_id, user_id, event_id, seats[], payment_ref, timestamps)
Annual: 200M × 500 bytes = 100 GB/year
→ Modest. Standard Postgres with partitioning by event_date.
Seat availability (hot state):
Per event during sale: 70K seats × 50 bytes (seat_id, state, holder, expiry)
= 3.5 MB per event in Redis
→ Even 1000 concurrent sales = 3.5 GB in Redis. Trivial.
Implication:
→ This is NOT a storage-heavy system. It's a concurrency-heavy system.
→ The entire hot inventory for a sale fits in Redis memory.
→ Postgres is the source of truth; Redis is the real-time coordination layer.
Bandwidth and connection load
Seat map polling (peak):
3.5M users × seat map response (5KB compressed) every 3 seconds
= 3.5M × 5KB / 3s = 5.8 GB/s outbound
→ This MUST go through CDN with short TTL (1-2 seconds)
→ Origin only serves cache misses
WebSocket connections (real-time seat updates):
If using WebSocket for live seat map:
3.5M concurrent connections × 20KB state each = 70 GB memory
→ 7,000 WebSocket servers at 500K connections each? Too expensive.
→ Better: short-polling with 2-second CDN TTL for seat map
→ WebSocket only for the user's own booking status (much fewer connections)
API gateway load:
1.4M read requests/sec + 58K write requests/sec = ~1.5M req/sec
→ 150 API servers at 10K req/sec each
→ Auto-scale from 20 servers (normal) to 150 (peak) in <60 seconds
Implication:
→ CDN absorbs 95%+ of read traffic (seat map is same for all users)
→ Only reservation writes hit origin servers
→ WebSocket is overkill for seat map — short-poll with CDN is cheaper and simpler
🧮 The numbers that drive the design
Quick Revision Cheat Sheet
Peak concurrent users: ~3.5M (high-demand sale)
Seat map read QPS (peak): ~1.4M req/sec
Reservation write QPS (peak): ~58K attempts/sec
Contention ratio (popular event): 50:1 (users:seats)
Hot seat contention: ~10K concurrent attempts/seat
Seat hold duration: 10 minutes TTL
Hot inventory per event (Redis): ~3.5 MB
Total event metadata: ~1 GB (cacheable)
Booking storage (annual): ~100 GB
CDN bandwidth (peak): ~5.8 GB/s outbound
💡 The insight that separates senior answers
Most candidates focus on total throughput. The real constraint is the bimodal traffic pattern — 99% idle, 1% absolute chaos. You can't provision for peak permanently (too expensive), and you can't scale fast enough reactively (sale starts in seconds). The answer is a virtual waiting room that absorbs the spike at the edge and meters traffic to the booking service at a rate it can handle. The waiting room IS the scaling strategy.
API Design
The API design for a ticket booking system must enforce the two-phase booking flow: hold first, then pay. This separation is critical because payment takes 5-30 seconds (external gateway), and you can't lock a seat for that long without a dedicated hold mechanism. Every write endpoint must carry an to prevent double-bookings from retries.
Core booking flow endpoints
The booking lifecycle has three phases: hold → pay → confirm. Each phase is a separate API call with its own failure semantics. The hold is optimistic (fast, may fail if seat is taken), the payment is external (slow, may timeout), and the confirmation is internal (must be reliable).
Request:
POST /api/v1/reservations
Headers:
Authorization: Bearer <jwt>
Idempotency-Key: "uuid-v4-client-generated"
X-Queue-Token: "waiting-room-token" // proves user passed the queue
Body:
{
"event_id": "evt_taylor_swift_2026",
"seats": [
{ "section": "A", "row": "12", "seat": "7" },
{ "section": "A", "row": "12", "seat": "8" }
],
"hold_duration_seconds": 600 // server may override to enforce max
}
Response (201 Created):
{
"reservation_id": "res_8f3a2b1c",
"status": "held",
"seats": [...],
"expires_at": "2026-03-15T10:10:00Z", // 10 min from now
"payment_deadline": "2026-03-15T10:10:00Z",
"links": {
"payment": "/api/v1/reservations/res_8f3a2b1c/payment",
"cancel": "/api/v1/reservations/res_8f3a2b1c"
}
}
Response (409 Conflict — seat already held):
{
"error": "SEAT_UNAVAILABLE",
"message": "One or more seats are no longer available",
"unavailable_seats": [{ "section": "A", "row": "12", "seat": "7" }],
"suggestion": "Try /api/v1/events/evt_.../best-available?count=2"
}
Request:
POST /api/v1/reservations/res_8f3a2b1c/payment
Headers:
Authorization: Bearer <jwt>
Idempotency-Key: "uuid-v4-payment-attempt"
Body:
{
"payment_method": "card",
"payment_token": "tok_visa_4242", // tokenized by client-side SDK
"amount_cents": 15000,
"currency": "USD"
}
Response (202 Accepted — payment processing):
{
"payment_id": "pay_9d4e3f2a",
"status": "processing",
"reservation_id": "res_8f3a2b1c",
"poll_url": "/api/v1/payments/pay_9d4e3f2a/status"
}
Response (410 Gone — hold expired):
{
"error": "HOLD_EXPIRED",
"message": "Your seat hold expired. Seats have been released.",
"expired_at": "2026-03-15T10:10:00Z"
}
Request:
GET /api/v1/events/evt_taylor_swift_2026/seats?section=A
Headers:
Cache-Control: max-age=2 // client accepts 2-second stale data
Response (200 OK):
{
"event_id": "evt_taylor_swift_2026",
"section": "A",
"updated_at": "2026-03-15T10:00:03Z",
"seats": [
{ "row": "1", "seat": "1", "status": "sold", "tier": "vip" },
{ "row": "1", "seat": "2", "status": "held", "tier": "vip" },
{ "row": "1", "seat": "3", "status": "available", "tier": "vip", "price_cents": 25000 },
...
],
"summary": {
"total": 500,
"available": 312,
"held": 45,
"sold": 143
}
}
Cache strategy:
→ CDN caches this response for 2 seconds (short TTL)
→ 3.5M users polling every 3s = only ~1 origin hit per 2 seconds per section
→ Seat map is "eventually consistent" for reads (acceptable)
→ Reservation writes are strongly consistent (non-negotiable)
Best-available seat selection
Many users don't want to pick individual seats — they want "the best 4 seats together." This is algorithmically harder because the system must atomically find AND reserve contiguous seats, avoiding the race condition where two users both find the same "best" seats.
Request:
POST /api/v1/events/evt_taylor_swift_2026/best-available
Headers:
Authorization: Bearer <jwt>
Idempotency-Key: "uuid-v4"
X-Queue-Token: "waiting-room-token"
Body:
{
"count": 4,
"preferences": {
"contiguous": true, // seats must be adjacent
"tier": "premium", // price tier preference
"max_price_cents": 20000 // per-seat budget
}
}
Response (201 Created):
{
"reservation_id": "res_7c2d1e4f",
"status": "held",
"seats": [
{ "section": "B", "row": "5", "seat": "10", "price_cents": 18000 },
{ "section": "B", "row": "5", "seat": "11", "price_cents": 18000 },
{ "section": "B", "row": "5", "seat": "12", "price_cents": 18000 },
{ "section": "B", "row": "5", "seat": "13", "price_cents": 18000 }
],
"expires_at": "2026-03-15T10:10:00Z"
}
Response (404 — no matching seats available):
{
"error": "NO_MATCHING_SEATS",
"message": "No 4 contiguous premium seats available under $200",
"alternatives": [
{ "tier": "premium", "count": 2, "price_cents": 18000 },
{ "tier": "standard", "count": 4, "price_cents": 12000 }
]
}
Waiting room integration
During high-demand sales, users must pass through a virtual waiting room before they can access booking endpoints. The queue token proves the user waited their turn and prevents direct API abuse.
1. User enters waiting room:
GET /api/v1/events/evt_.../queue/join
→ Returns: { "position": 45231, "estimated_wait": "4m 30s", "token": null }
2. User polls for position updates:
GET /api/v1/events/evt_.../queue/status?session=<session_id>
→ Returns: { "position": 12003, "estimated_wait": "1m 15s", "token": null }
3. User reaches front of queue:
GET /api/v1/events/evt_.../queue/status?session=<session_id>
→ Returns: { "position": 0, "token": "qt_signed_jwt_...", "expires_in": 300 }
4. User uses token to access booking APIs:
POST /api/v1/reservations
Headers: X-Queue-Token: "qt_signed_jwt_..."
→ Server validates: signature, expiry, event_id match, single-use
Token properties:
- Signed JWT (HMAC-SHA256) — can't be forged
- Contains: user_id, event_id, issued_at, expires_at
- Single-use: marked as consumed after first successful reservation
- Expires in 5 minutes if unused (user took too long after leaving queue)
API design decisions
| Decision | Choice | Why |
|---|---|---|
| Hold + Pay separation | Two separate API calls | Payment is slow (5-30s). Can't lock a seat for that long without explicit hold. Enables timeout-based release. |
| Idempotency keys on all writes | Client-generated UUID per request | Network retries, double-clicks, and mobile reconnects would otherwise cause double-bookings or double-charges. |
| Queue token for write access | Signed JWT from waiting room | Prevents bots from bypassing the queue. Rate-limits access to booking endpoints during high-demand sales. |
| Seat map via REST + CDN | Short-poll with 2s TTL, not WebSocket | 3.5M WebSocket connections is expensive. CDN absorbs 99% of reads. 2-second staleness is acceptable for the map. |
| 409 with suggestions on conflict | Return alternatives, not just error | User experience: instead of 'seat taken, try again', offer 'here are similar available seats'. Reduces retry storms. |
💡 Interview tip
Interviewers love asking: "What happens if the user double-clicks the reserve button?" The answer is the idempotency key — the second request with the same key returns the cached result of the first. No double-hold, no race condition. This is a production concern that shows you've built real systems.
Data Model
The data model for a ticket booking system has two distinct layers: a cold layer (Postgres) that stores the permanent source of truth — events, venues, bookings, payment records — and a hot layer (Redis) that manages real-time seat state during active sales. The cold layer optimizes for correctness and durability. The hot layer optimizes for atomic operations under extreme concurrency. Understanding which data lives where — and why — is what separates a production design from a textbook one.
Cold layer: Postgres (source of truth)
The relational schema stores all permanent data. Venue layouts rarely change, events are created days/weeks before sale, and booking records are append-only after confirmation. This layer handles queries like "show me all bookings for user X" or "how many seats are sold for event Y" — but it does NOT handle real-time seat reservation during a live sale.
-- Venues are static — created once, rarely updated
CREATE TABLE venues (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
city TEXT NOT NULL,
country TEXT NOT NULL,
total_capacity INT NOT NULL,
seat_map_version INT DEFAULT 1, -- bumped when layout changes
created_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ,
delete_info JSONB
);
-- Sections within a venue (e.g., "Floor", "Balcony Left", "VIP Box")
CREATE TABLE venue_sections (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
venue_id UUID REFERENCES venues(id),
name TEXT NOT NULL, -- "Section A", "Floor", "Balcony"
tier TEXT NOT NULL, -- "vip", "premium", "standard", "economy"
row_count INT NOT NULL,
seats_per_row INT NOT NULL,
created_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ,
delete_info JSONB
);
-- Individual seats — pre-generated from venue layout
CREATE TABLE seats (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
venue_id UUID REFERENCES venues(id),
section_id UUID REFERENCES venue_sections(id),
row_label TEXT NOT NULL, -- "A", "B", "1", "2"
seat_number INT NOT NULL,
x_coord FLOAT, -- for seat map rendering
y_coord FLOAT,
is_accessible BOOLEAN DEFAULT false, -- wheelchair accessible
created_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ,
delete_info JSONB,
UNIQUE(venue_id, section_id, row_label, seat_number)
);
-- Events — the thing users buy tickets for
CREATE TABLE events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
venue_id UUID REFERENCES venues(id),
title TEXT NOT NULL,
artist TEXT,
category TEXT NOT NULL, -- "concert", "sports", "theater"
event_date TIMESTAMPTZ NOT NULL,
sale_start TIMESTAMPTZ NOT NULL, -- when tickets go on sale
sale_end TIMESTAMPTZ,
status TEXT NOT NULL DEFAULT 'upcoming', -- upcoming, on_sale, sold_out, completed, cancelled
max_tickets_per_user INT DEFAULT 6,
created_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ,
delete_info JSONB
);
-- Pricing per section per event (same venue, different prices per event)
CREATE TABLE event_pricing (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_id UUID REFERENCES events(id),
section_id UUID REFERENCES venue_sections(id),
price_cents INT NOT NULL,
currency TEXT NOT NULL DEFAULT 'USD',
created_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ,
delete_info JSONB,
UNIQUE(event_id, section_id)
);
-- Bookings — the confirmed purchase record (append-only)
CREATE TABLE bookings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
event_id UUID REFERENCES events(id),
status TEXT NOT NULL DEFAULT 'confirmed', -- confirmed, cancelled, refunded
total_amount_cents INT NOT NULL,
currency TEXT NOT NULL,
payment_id TEXT, -- external payment reference
booked_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ,
delete_info JSONB
);
-- Booked seats — links bookings to specific seats
CREATE TABLE booking_seats (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
booking_id UUID REFERENCES bookings(id),
event_id UUID REFERENCES events(id),
seat_id UUID REFERENCES seats(id),
price_cents INT NOT NULL,
created_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ,
delete_info JSONB,
UNIQUE(event_id, seat_id) -- a seat can only be booked once per event
);
Hot layer: Redis (real-time seat coordination)
During an active sale, all seat state transitions happen in Redis. Postgres is too slow for the concurrency requirements — a single Postgres row lock under 10K concurrent writers would create unacceptable latency. Redis provides atomic operations (SETNX, compare-and-swap via Lua scripts) with sub-millisecond latency. After a booking is confirmed, the result is written back to Postgres as the permanent record.
# Seat state — one key per seat per event
# Key pattern: seat:{event_id}:{seat_id}
# Value: JSON with state, holder, and expiry
SET seat:evt_123:seat_A12_7 '{
"state": "held",
"holder_id": "user_abc",
"reservation_id": "res_8f3a2b1c",
"held_at": 1710496200,
"expires_at": 1710496800
}' EX 600 // TTL = 10 minutes (auto-release on expiry)
# When seat is available: key does NOT exist in Redis
# When seat is held: key exists with TTL
# When seat is sold: key exists with NO TTL (permanent until event ends)
# ─────────────────────────────────────────────────────────────
# Event-level availability summary (for fast seat map responses)
# Updated atomically on every state transition
HSET event:evt_123:availability section_A_available 312
HSET event:evt_123:availability section_A_held 45
HSET event:evt_123:availability section_A_sold 143
# ─────────────────────────────────────────────────────────────
# User's active holds (to enforce max-tickets-per-user)
# Key pattern: user_holds:{event_id}:{user_id}
SADD user_holds:evt_123:user_abc "seat_A12_7" "seat_A12_8"
EXPIRE user_holds:evt_123:user_abc 600 // same TTL as seat hold
# ─────────────────────────────────────────────────────────────
# Idempotency store (prevent double-reservations from retries)
# Key pattern: idempotency:{idempotency_key}
SET idempotency:uuid-v4-abc '{"reservation_id":"res_8f3a2b1c","status":"held"}'
EXPIRE idempotency:uuid-v4-abc 3600 // keep for 1 hour
Why two layers? (bad → good → optimal)
The choice of where to manage seat state during a live sale is the most critical data model decision. Let's walk through the evolution from naive to production-grade:
| Approach | How it works | Throughput | Verdict |
|---|---|---|---|
| ❌ Postgres row locks only | SELECT FOR UPDATE on the seat row. Hold the lock while processing. Update state on commit. | ~500 reservations/sec per seat (lock wait dominates). 10K concurrent users = 20-second wait times. | Unacceptable. Row-level locks serialize all access. A hot seat becomes a single-threaded bottleneck. |
| ⚠️ Postgres with optimistic locking | Read seat version, attempt UPDATE with WHERE version = X. Retry on conflict. | ~2K reservations/sec. Better, but retries under high contention cause retry storms. | Better for moderate load. Fails at 10K+ concurrent users on the same seat — retry amplification. |
| ✅ Redis SETNX + Postgres write-behind | Atomic SETNX in Redis (succeeds or fails in <1ms). On success, async write to Postgres. On failure, instant rejection. | ~100K+ operations/sec. Failed attempts return in <1ms. No lock waiting, no retries needed. | Production choice. Redis handles the hot path; Postgres is the durable record. Best of both worlds. |
The optimal approach uses Redis as the and Postgres as the durable store. Redis decides who gets the seat (fast, atomic). Postgres remembers who got it (durable, queryable). The write-behind pattern ensures Postgres eventually has the complete booking record, but the real-time decision happens entirely in Redis.
Seat map data structure
The seat map is a pre-computed static layout (coordinates for rendering) combined with dynamic availability state. The static layout is loaded once from Postgres and cached indefinitely. The dynamic state is fetched from Redis (or CDN-cached with short TTL) on every poll.
Static layout (cached forever, invalidated only on venue renovation):
Source: Postgres → CDN (immutable until seat_map_version changes)
Contains: section boundaries, row positions, seat coordinates, tier colors
Size: ~200KB per venue (compressed SVG paths + seat coordinates)
Dynamic availability (refreshed every 2 seconds):
Source: Redis → API → CDN (2-second TTL)
Contains: per-seat status (available/held/sold)
Size: ~5KB per section (just status enum per seat)
Client composition:
1. Load static layout once (from CDN, cached in browser)
2. Poll dynamic availability every 2-3 seconds (from CDN)
3. Overlay availability on layout → render colored seat map
→ Static + dynamic separation means 99% of bandwidth is the tiny
availability payload, not the full seat map geometry.
🔑 Key schema decisions
- Seats are pre-generated per venue — not created per event. An event references a venue's existing seats. This avoids duplicating 70K seat records for every concert at Madison Square Garden.
- Pricing is per-event-per-section — the same venue section can have different prices for different events. Taylor Swift VIP ≠ local band VIP.
- booking_seats has a UNIQUE(event_id, seat_id) constraint — this is the database-level guarantee against double-booking. Even if Redis fails, Postgres prevents overselling.
- Redis keys auto-expire — if the booking service crashes mid-hold, the TTL ensures seats are released automatically. No manual cleanup needed.
High-Level Architecture
The architecture splits into two fundamentally different modes: a browse path (read-heavy, cacheable, eventually consistent) and a booking path (write-heavy, strongly consistent, latency-critical). Separating these paths allows independent scaling: the browse path scales horizontally with CDN edge nodes, while the booking path scales vertically with Redis cluster throughput.
System components
Waiting Room Service
Absorbs traffic spikes at the edge. Queues users and issues signed tokens that grant access to booking APIs. Runs on CDN edge workers.
Event Service
Serves event metadata, search, and discovery. Read-heavy, fully cacheable. Backed by Postgres + Elasticsearch, fronted by CDN with 60s TTL.
Inventory Service
Manages real-time seat state in Redis. Handles hold/release/sell transitions via atomic Lua scripts. Single source of truth for seat availability.
Reservation Service
Orchestrates the booking saga: validates queue token, calls Inventory to hold seats, initiates payment, confirms or rolls back.
Payment Service
Interfaces with external payment gateways. Handles idempotent charges, webhook processing, and refund orchestration.
Notification Service
Sends booking confirmations, e-tickets, hold expiration warnings via email, SMS, and push. Async via message queue.
Browse path (read flow)
The browse path handles event discovery and seat map viewing. It serves 99% of all traffic but has relaxed consistency requirements — a seat showing as "available" for 2 extra seconds after being held is acceptable. This path is designed to never hit the origin database under normal load.
User
Browses events, views seat map
CDN Edge
Serves cached event data (60s TTL) and seat availability (2s TTL)
API Gateway
Only on cache miss. Routes to Event Service or Inventory Service
Event Service
Queries Postgres/Elasticsearch for event metadata
Redis
Serves current seat availability for seat map rendering
During a high-demand sale, the CDN absorbs 95%+ of seat map requests. With a 2-second TTL, even 3.5M users polling every 3 seconds generate only ~1 origin request per 2 seconds per section. The seat map is "eventually consistent" — users may see a seat as available for up to 2 seconds after it's been held. This is acceptable because the reservation endpoint will reject the attempt with a clear error if the seat is actually taken.
Booking path (write flow)
The booking path is the critical path — it must be strongly consistent, fast, and resilient. Every request on this path has passed through the waiting room (traffic is metered) and carries a signed queue token.
User (with queue token)
Submits seat reservation request
API Gateway
Validates JWT, rate limits, routes to Reservation Service
Reservation Service
Validates queue token, checks user limits, orchestrates saga
Inventory Service (Redis)
Atomic SETNX — holds seat or rejects instantly
Payment Service
Charges payment gateway, handles webhook confirmation
Postgres
Writes confirmed booking record (durable source of truth)
The key insight: the waiting room ensures the booking path never receives more traffic than it can handle. If the Inventory Service can process 50K reservations/sec, the waiting room meters exactly that many users through per second. The booking path is always operating within its capacity — no overload, no degradation.
Async path (background processing)
Several operations don't need to happen synchronously during the booking flow. They're offloaded to a to keep the booking path fast.
Events published to Kafka after booking confirmation:
1. booking.confirmed
→ Notification Service: send confirmation email + e-ticket PDF
→ Analytics Service: update sales dashboard, revenue tracking
→ Inventory Service: write-behind to Postgres (permanent record)
2. seat.hold_expired
→ Inventory Service: release seat in Redis (TTL handles this automatically)
→ Notification Service: "Your hold expired" push notification
→ Waitlist Service: notify next user in waitlist (if applicable)
3. payment.failed
→ Reservation Service: release held seats immediately (don't wait for TTL)
→ Notification Service: "Payment failed, seats released" email
4. event.sold_out
→ Event Service: update event status, stop accepting queue entries
→ Notification Service: notify waitlisted users
→ CDN: purge cached seat map (force refresh to show sold-out state)
🔑 Why this separation matters
- Failure isolation: If the Notification Service is down, bookings still succeed. Each path fails independently.
- Independent scaling: Browse path scales with CDN. Booking path scales with Redis cluster. They don't compete for the same resources.
- Different SLOs: Browse: 200ms p99, eventual consistency. Booking: 500ms p99, strong consistency.
- Cost efficiency: 95% of traffic is served from CDN edge — pennies per million requests. Only 5% hits origin.
💡 Interview framing
When drawing the architecture, explicitly label the two paths and their consistency models. Say: "The browse path is eventually consistent with 2-second staleness — acceptable because the reservation endpoint is the real gatekeeper. The booking path is strongly consistent — Redis SETNX guarantees exactly-once seat allocation. The waiting room bridges the two: it absorbs the million-user spike and meters traffic to the booking path at a rate it can handle."
Seat Inventory State Machine
Every seat in the system exists in exactly one state at any given moment. The state machine defines which transitions are legal, who can trigger them, and what happens when transitions race against each other. Getting this wrong means double-bookings. Getting it right means the system is provably correct regardless of concurrency.
State Diagram
A seat has four possible states. Transitions are unidirectional except for the hold-expiration path which returns a seat to available. The "sold" state is terminal — once a seat is sold, it never returns to the pool (refunds create a new "available" event, they don't reverse the state).
┌───────────┐ reserve() ┌────────┐ payment_confirmed() ┌────────┐
│ AVAILABLE │ ──────────────────→ │ HELD │ ─────────────────────────→ │ SOLD │
└───────────┘ └────────┘ └────────┘
↑ │
│ hold_expired() │
│ user_cancelled() │
└────────────────────────────────┘
↑
│ refund_processed()
│ (creates new availability — not a state reversal)
└──────────────────────────────────────────────────────────────────────┘
Invalid transitions (must be rejected):
AVAILABLE → SOLD (can't skip the hold — payment takes time)
HELD → HELD (can't re-hold an already-held seat)
SOLD → AVAILABLE (refunds are a separate business event)
SOLD → HELD (impossible — sold is terminal)
Implementation: Bad → Good → Optimal
Bad: Postgres UPDATE with no concurrency control
The naive approach is a simple UPDATE statement. Two users select the same seat, both read status = 'available', both issue UPDATE SET status = 'held'. One overwrites the other. Classic .
-- User A and User B both execute this simultaneously for seat_A1:
UPDATE event_seats
SET status = 'held', held_by = :userId, held_until = NOW() + INTERVAL '10 min'
WHERE event_id = :eventId AND seat_id = :seatId AND status = 'available';
-- Problem: Both read status='available' before either writes.
-- Both UPDATEs succeed. Last writer wins. First user's hold is silently lost.
-- Result: User A thinks they have the seat, but User B actually holds it.
-- User A proceeds to payment for a seat they don't own → double-booking.
Good: Postgres with optimistic locking (version column)
Add a version column and include it in the WHERE clause. If another transaction modified the row between your read and write, the UPDATE affects 0 rows and you know to retry or reject. This prevents lost updates but has performance issues under high contention.
-- Read current state
SELECT status, version FROM event_seats
WHERE event_id = :eventId AND seat_id = :seatId;
-- Returns: status='available', version=3
-- Attempt update with version check
UPDATE event_seats
SET status = 'held',
held_by = :userId,
held_until = NOW() + INTERVAL '10 min',
version = version + 1
WHERE event_id = :eventId
AND seat_id = :seatId
AND status = 'available'
AND version = 3; -- fails if version changed
-- If rows_affected = 0 → seat was taken, return 409 Conflict
-- If rows_affected = 1 → hold acquired successfully
-- Problem at scale:
-- 1000 users click same seat → 1000 concurrent transactions
-- 999 get rows_affected=0 → must retry or fail
-- Postgres row-level lock contention causes latency spikes
-- Connection pool exhaustion under thundering herd
Optimal: Redis Lua script — atomic single-attempt resolution
Move the hot seat state to Redis and use a for atomic check-and-set. Redis is single-threaded — the script executes without interleaving. One user wins, all others get an immediate rejection. No retries, no lock contention, no connection pool exhaustion.
-- KEYS[1] = "event:{eventId}:seats"
-- KEYS[2] = "event:{eventId}:holds"
-- KEYS[3] = "event:{eventId}:available"
-- ARGV[1] = seat_id (e.g., "seat_A1")
-- ARGV[2] = user_id
-- ARGV[3] = reservation_id
-- ARGV[4] = expiry_timestamp (unix seconds)
-- ARGV[5] = section (e.g., "Floor_A")
-- Step 1: Check current state
local current = redis.call('HGET', KEYS[1], ARGV[1])
if current ~= 'available' then
return {0, current} -- REJECTED: seat not available, return current state
end
-- Step 2: Atomically claim the seat
local hold_value = 'held:' .. ARGV[2] .. ':' .. ARGV[4]
redis.call('HSET', KEYS[1], ARGV[1], hold_value)
-- Step 3: Register hold expiration
local hold_entry = ARGV[1] .. ':' .. ARGV[2] .. ':' .. ARGV[3]
redis.call('ZADD', KEYS[2], tonumber(ARGV[4]), hold_entry)
-- Step 4: Decrement available count for section
redis.call('HINCRBY', KEYS[3], ARGV[5], -1)
return {1, 'held'} -- SUCCESS: seat is now held
This script executes in ~0.1ms on Redis. For 1,000 concurrent users clicking the same seat: one gets {1, 'held'}, the other 999 get {0, 'held:user_X:...'} immediately. No retries. No waiting. Total time for all 1,000 responses: ~100ms.
Approach Comparison
The three approaches differ dramatically in how they handle the core challenge: 1,000 users clicking the same seat within 1 second.
| Approach | Correctness | Latency (1K concurrent) | Failure Mode |
|---|---|---|---|
| Naive UPDATE | ❌ Race condition → double-booking | ~50ms (but wrong result) | Silent data corruption |
| Optimistic locking | ✅ Correct (version check) | ~200-500ms (lock contention) | Connection pool exhaustion, timeouts |
| Redis Lua script | ✅ Correct (atomic execution) | ~0.1ms per request, ~100ms total | Redis failure → fallback to Postgres |
Multi-Seat Atomic Reservation
Users often book multiple seats together (e.g., 4 tickets for a family). The reservation must be atomic — either all seats are held or none are. Partial holds create a terrible UX where a family gets split across the venue.
-- KEYS[1] = "event:{eventId}:seats"
-- ARGV = [seat_id_1, seat_id_2, ..., seat_id_N, user_id, expiry, res_id]
local seat_count = #ARGV - 3 -- last 3 args are user_id, expiry, res_id
local user_id = ARGV[seat_count + 1]
local expiry = ARGV[seat_count + 2]
local res_id = ARGV[seat_count + 3]
-- Phase 1: Check ALL seats are available (read phase)
for i = 1, seat_count do
local status = redis.call('HGET', KEYS[1], ARGV[i])
if status ~= 'available' then
return {0, ARGV[i], status} -- REJECTED: which seat failed and why
end
end
-- Phase 2: Claim ALL seats atomically (write phase)
-- If we reach here, all seats were available at check time.
-- Since Lua scripts are atomic, no other script can interleave.
for i = 1, seat_count do
local hold_value = 'held:' .. user_id .. ':' .. expiry
redis.call('HSET', KEYS[1], ARGV[i], hold_value)
end
return {1, seat_count} -- SUCCESS: all seats held
⚠️ Adjacent seat constraint
If the requirement is "4 seats must be adjacent in the same row," the Lua script must also validate adjacency before claiming. This adds complexity but remains atomic within the script. For general admission (no assigned seats), the problem reduces to a simple counter decrement: DECRBY event:{id}:ga_available 4.
💡 Interviewer signal
Walking through all three approaches — explaining why the naive one fails, why optimistic locking is correct but slow, and why Redis Lua is optimal — is the strongest signal in this problem. It shows you understand concurrency at a fundamental level, not just "use Redis."
Reservation & Hold Management
The hold is the most critical temporal construct in the system. It bridges the gap between "user selected a seat" and "user paid for it." Too short and users can't complete checkout. Too long and inventory is locked while demand is at peak. The hold timer must be reliable, precise, and handle edge cases like payment-in-progress when the timer fires.
Hold Lifecycle
A hold begins when the Redis Lua script successfully claims a seat. It ends in one of three ways: the user pays (hold converts to sold), the user cancels (immediate release), or the timer expires (automatic release). Each path has different consistency requirements.
T+0:00 User clicks "Reserve" → Lua script claims seat → hold starts
T+0:01 Client receives confirmation, shows 9:59 countdown
T+0:30 User enters payment details
T+2:00 User clicks "Pay" → payment processing begins
T+2:05 Payment gateway responds "success"
T+2:06 Hold converts to "sold" → seat permanently allocated
─── OR ───
T+0:00 Hold starts (10:00 remaining)
T+5:00 User abandons page (no payment attempt)
T+10:00 Hold expires → expiration worker releases seat
T+10:01 Seat appears as "available" to other users
─── OR (the dangerous edge case) ───
T+0:00 Hold starts
T+9:50 User clicks "Pay" (10 seconds before expiry)
T+9:55 Payment gateway processing...
T+10:00 Hold timer fires! But payment is in-flight...
T+10:03 Payment gateway returns "success"
→ Race condition: is the seat sold or released?
Hold Expiration: Bad → Good → Optimal
Bad: Redis KEY expiration (TTL on individual keys)
The naive idea: create a Redis key per hold with a 10-minute TTL. When the key expires, Redis fires a keyspace notification and you release the seat. This sounds elegant but has critical reliability problems.
// Set hold with TTL
await redis.set(`hold:${eventId}:${seatId}`, userId, 'EX', 600);
// Subscribe to expiration events
redis.subscribe('__keyevent@0__:expired', (key) => {
// Problem 1: Keyspace notifications are "fire and forget"
// If your subscriber is down when the key expires, the event is LOST.
// The seat stays "held" forever in the seats hash.
// Problem 2: Under memory pressure, Redis may evict keys early
// Your hold expires at 3 minutes instead of 10 — terrible UX.
// Problem 3: No guaranteed delivery order
// Events can arrive out of order or be delayed by seconds.
// Problem 4: Can't batch-process expirations efficiently
// Each expiration is a separate event — no bulk operations.
});
Good: Polling-based expiration with Sorted Set
Store all holds in a with the expiry timestamp as the score. A worker polls every second, fetches all members with score < now, and releases them. This is reliable but introduces up to 1 second of delay.
// When creating a hold:
const expiryTimestamp = Math.floor(Date.now() / 1000) + 600; // 10 min
await redis.zadd(`event:${eventId}:holds`, expiryTimestamp, holdEntry);
// Expiration worker (runs every 1 second):
async function processExpiredHolds(eventId: string) {
const now = Math.floor(Date.now() / 1000);
// Fetch all holds that have expired
const expired = await redis.zrangebyscore(
`event:${eventId}:holds`, '-inf', now
);
for (const holdEntry of expired) {
const [seatId, userId, reservationId] = holdEntry.split(':');
await releaseSeat(eventId, seatId, userId, reservationId);
}
}
// Limitation: up to 1 second delay between actual expiry and release.
// During that 1 second, the seat appears "held" but is actually expired.
// Another user trying to book sees "unavailable" for a stale hold.
Optimal: Sorted Set + lazy expiration on read
Combine the polling worker with lazy expiration: when any user attempts to reserve a seat that's "held," the booking service checks if the hold has expired. If yes, it releases the seat inline and grants it to the new user — all within the same Lua script. This eliminates the 1-second gap.
-- Enhanced seat claim script with lazy expiration
-- KEYS[1] = "event:{eventId}:seats"
-- ARGV[1] = seat_id, ARGV[2] = user_id, ARGV[3] = expiry, ARGV[4] = now
local current = redis.call('HGET', KEYS[1], ARGV[1])
if current == 'available' then
-- Seat is free, claim it
redis.call('HSET', KEYS[1], ARGV[1], 'held:' .. ARGV[2] .. ':' .. ARGV[3])
return {1, 'claimed'}
end
-- Seat is held — check if the hold has expired
if string.sub(current, 1, 4) == 'held' then
local parts = {}
for part in string.gmatch(current, '[^:]+') do
table.insert(parts, part)
end
local hold_expiry = tonumber(parts[3])
local now = tonumber(ARGV[4])
if now >= hold_expiry then
-- Hold expired! Release and grant to new user in one atomic step
redis.call('HSET', KEYS[1], ARGV[1], 'held:' .. ARGV[2] .. ':' .. ARGV[3])
return {1, 'claimed_after_expiry'}
else
-- Hold is still valid
return {0, 'held_by_other', hold_expiry - now} -- seconds remaining
end
end
-- Seat is sold — permanent rejection
return {0, 'sold'}
Now there's zero gap: the background worker handles bulk cleanup (updating available counts, notifying UIs), while the Lua script handles the race condition where a new user tries to book an expired-but-not-yet-cleaned-up seat.
The Payment-Expiration Race
The most dangerous edge case: a user clicks "Pay" at T+9:50 (10 seconds before hold expires). The payment gateway takes 5 seconds to respond. At T+10:00, the expiration worker fires. Who wins?
// Payment confirmation handler
async function confirmPayment(reservationId: string, paymentResult: PaymentResult) {
// Use a Lua script that atomically checks seat state before confirming
const result = await redis.eval(CONFIRM_PAYMENT_SCRIPT, {
keys: [`event:${eventId}:seats`],
args: [seatId, reservationId, 'sold']
});
// The Lua script:
// 1. Check if seat is still "held" by THIS reservation
// 2. If yes → set to "sold" (payment wins)
// 3. If seat was already released (expiration won) → payment fails
// → trigger refund for the charge that already went through
if (result === 'already_released') {
// Expiration worker beat us. Seat was given to someone else.
// We must refund the payment that just succeeded.
await refundPayment(paymentResult.chargeId);
return { status: 'expired', message: 'Hold expired during payment' };
}
return { status: 'confirmed', bookingId: result };
}
// Key insight: the Lua script is the single arbiter.
// Both the expiration worker and the payment handler go through it.
// Whoever's Lua script executes first wins. The other gets a clean rejection.
| Scenario | Who Wins | Recovery Action |
|---|---|---|
| Payment confirms at T+9:55, expiry at T+10:00 | Payment wins (Lua sets 'sold' before expiry runs) | None needed — happy path |
| Expiry fires at T+10:00, payment confirms at T+10:03 | Expiration wins (seat already released) | Refund the payment, notify user 'hold expired' |
| User cancels at T+5:00 | User wins (immediate release) | None — seat returns to pool instantly |
| Redis crashes during hold | Nobody wins | Postgres reservation record has expiry — fallback expiration via DB query |
⏱️ Hold duration trade-offs
- 10 minutes (industry standard) — enough for most users to complete payment. Ticketmaster, BookMyShow, and most platforms use this.
- 5 minutes (aggressive) — reduces inventory lock time during extreme demand. Used for flash sales or GA events.
- 15 minutes (generous) — better UX for complex checkouts (group bookings, accessibility needs). Costs more locked inventory.
- Dynamic hold — start at 10 min, extend by 2 min if user is actively on the payment page. Requires heartbeat from client.
💡 Interviewer signal
The payment-expiration race is the question interviewers are waiting to ask. Having the answer ready — "both paths go through the same Lua script, whoever executes first wins, the loser triggers compensation" — shows you've thought about the hardest edge case, not just the happy path.
Distributed Locking & Seat Allocation
When multiple booking service instances process reservations concurrently, you need a strategy to prevent two instances from claiming the same seat. The Redis Lua approach from Section 06 handles single-seat atomicity, but what about coordinating across multiple Redis instances, handling Redis failures, and managing "best available" seat allocation where the system picks seats for the user?
Locking Strategy: Bad → Good → Optimal
Bad: Distributed lock per seat (Redlock)
The instinct is to acquire a on each seat before modifying it. This is correct but catastrophically slow for ticket booking — acquiring a lock across 5 Redis instances takes 10-50ms, and you're doing this for every single seat reservation attempt.
// Acquire distributed lock on the seat
const lock = await redlock.acquire(`lock:seat:${seatId}`, 5000);
try {
// Read current state
const status = await redis.hget(`event:${eventId}:seats`, seatId);
if (status !== 'available') throw new SeatUnavailableError();
// Set to held
await redis.hset(`event:${eventId}:seats`, seatId, `held:${userId}`);
} finally {
await lock.release();
}
// Problems:
// 1. Lock acquisition: 10-50ms per seat (5 Redis round-trips for Redlock)
// 2. For 4-seat booking: 40-200ms just for locks
// 3. Under 8000 req/sec: lock contention causes cascading timeouts
// 4. Lock expiry edge cases: lock expires while processing → double-claim
// 5. Overkill: we don't need distributed consensus for single-instance Redis
Good: Single Redis instance with Lua atomicity
Since Redis is single-threaded, a Lua script on a single instance provides atomicity without any locking overhead. All seat state for one event lives on one Redis instance. No distributed coordination needed. This works perfectly until the single instance becomes a bottleneck or fails.
// All seats for event X live on one Redis instance
// Lua script provides atomicity (see Section 06)
const result = await redis.eval(CLAIM_SEAT_SCRIPT, {
keys: [`event:${eventId}:seats`],
args: [seatId, userId, expiry]
});
// Advantages:
// - Zero lock overhead (Lua is atomic on single instance)
// - 0.1ms per operation
// - Simple mental model
// Limitations:
// - Single Redis instance = single point of failure
// - If Redis goes down during a sale, ALL reservations fail
// - Single instance throughput ceiling: ~100K ops/sec
// (sufficient for most events, but not for multiple concurrent hot sales)
Optimal: Event-partitioned Redis with replica failover
Partition seat inventory by event across multiple Redis instances. Each event's entire seat map lives on one instance (preserving Lua atomicity), but different events are spread across the cluster. Each instance has a synchronous replica for failover. This gives you both atomicity and high availability.
// Event-to-Redis-instance mapping
function getRedisForEvent(eventId: string): Redis {
// Consistent hashing maps each event to a specific Redis primary
// All seat operations for that event go to the same instance
const slot = consistentHash(eventId, REDIS_INSTANCES.length);
return REDIS_INSTANCES[slot];
}
// Reservation flow
async function reserveSeat(eventId: string, seatId: string, userId: string) {
const redis = getRedisForEvent(eventId);
// Lua script runs on the single instance that owns this event
// No distributed locking needed — atomicity is guaranteed
const result = await redis.eval(CLAIM_SEAT_SCRIPT, {
keys: [`event:${eventId}:seats`, `event:${eventId}:holds`],
args: [seatId, userId, expiry, section]
});
return result;
}
// Failover: if primary dies, replica promotes automatically
// Sentinel or Redis Cluster handles promotion in <5 seconds
// During promotion window: reservations return 503, queue holds position
// After promotion: Lua scripts resume on new primary
// Scaling:
// 10 Redis instances → 10 concurrent hot sales without contention
// Each instance handles 100K ops/sec → 1M total cluster throughput
// Add instances as concurrent hot events grow
| Strategy | Latency | Availability | Complexity |
|---|---|---|---|
| Redlock per seat | 10-50ms (5 round-trips) | High (majority quorum) | High (clock skew, lock expiry races) |
| Single Redis + Lua | 0.1ms (single instance) | Low (SPOF) | Low (simple Lua scripts) |
| Event-partitioned + replica | 0.1ms (single instance per event) | High (replica failover) | Medium (consistent hashing + Sentinel) |
Best-Available Seat Allocation
Not all users pick specific seats. Many click "Best Available — 2 tickets" and expect the system to find optimal seats. This is a different problem from claiming a specific seat — it requires searching the available inventory and selecting seats based on a scoring algorithm (proximity to stage, adjacency, price tier).
// Best-available is a two-phase operation:
// Phase 1: Find candidates (read — can be slightly stale)
// Phase 2: Claim atomically (write — must be strongly consistent)
async function bestAvailable(eventId: string, count: number, preferences: Preferences) {
const redis = getRedisForEvent(eventId);
// Phase 1: Get all available seats in preferred sections
// This is a HSCAN or pre-computed sorted list
const available = await redis.eval(GET_AVAILABLE_SCRIPT, {
keys: [`event:${eventId}:seats`],
args: [preferences.section, preferences.priceTier]
});
// Score and rank candidates (adjacency, row proximity, aisle preference)
const ranked = scoreCandidates(available, count, preferences);
// Phase 2: Attempt to claim top-ranked candidates atomically
// If any are taken between Phase 1 and Phase 2, try next candidates
for (const candidateGroup of ranked) {
const result = await redis.eval(CLAIM_MULTIPLE_SEATS_SCRIPT, {
keys: [`event:${eventId}:seats`],
args: [...candidateGroup.seatIds, userId, expiry, reservationId]
});
if (result[0] === 1) return { success: true, seats: candidateGroup };
// If failed, try next candidate group (Lua script is still atomic)
}
return { success: false, reason: 'no_adjacent_seats_available' };
}
🎯 Allocation fairness
Best-available allocation introduces a subtle fairness problem: if the algorithm always picks the "best" remaining seats, early buyers get front-row and late buyers get nosebleeds. Some platforms randomize within tiers — you get a random seat in your price tier, not necessarily the "best" one. This prevents the perception that the system favors faster clickers.
💡 Interviewer signal
Distinguishing between "specific seat selection" (simple atomic claim) and "best available" (search + claim with retry) shows you understand that the same system has two different allocation modes with different performance characteristics. Most candidates only design for one.
Virtual Waiting Room
The virtual waiting room is the single most important component for handling high-demand ticket sales. Without it, 3.5 million users hitting the booking service simultaneously would overwhelm every downstream system — Redis, Postgres, payment gateways, everything. The waiting room acts as a layer — it absorbs the spike at the edge and meters users to the booking service at exactly the rate it can handle.
Why not just scale the booking service? (bad → good → optimal)
The naive answer to "how do you handle 3.5M users?" is "auto-scale." But auto-scaling has fundamental limits for this problem:
| Approach | How it works | Why it fails | Verdict |
|---|---|---|---|
| ❌ Auto-scale everything | Spin up more API servers, Redis nodes, and DB connections as traffic increases. | Auto-scaling takes 60-120 seconds. The spike hits in 5 seconds. By the time new instances are ready, the sale is over. Also: Redis cluster resharding under load is dangerous. | Too slow. The spike is faster than any auto-scaler. You'd need to pre-provision for peak (expensive) or accept failures. |
| ⚠️ Rate limiting at API gateway | Reject requests above a threshold (e.g., 50K req/sec). Return 429 Too Many Requests. | Users get random errors. No fairness — fast clickers and bots win. Terrible UX. Users rage-refresh, making it worse. | Protects backends but destroys user experience. Users don't know if they'll ever get through. |
| ✅ Virtual waiting room | Queue all users at the edge. Release them in controlled batches. Each user knows their position and estimated wait time. | None at the architecture level. Users have a fair, transparent experience. Backends receive steady, manageable traffic. | Production choice. Used by Ticketmaster (Smart Queue), AWS (CloudFront waiting room), and every major ticketing platform. |
Waiting room architecture
The waiting room runs at the CDN edge (Cloudflare Workers, Lambda@Edge, or CloudFront Functions). It intercepts all requests to booking endpoints and only allows users through if they have a valid queue token. Users without a token are placed in the queue and shown a waiting page.
ENTRY PHASE (sale starts):
1. User navigates to event page → CDN serves cached event info (normal)
2. User clicks "Buy Tickets" → request hits waiting room edge worker
3. Edge worker checks: does user have a valid queue token?
- YES → proxy request to booking service (user already waited)
- NO → assign queue position, return waiting room page
WAITING PHASE:
4. User sees: "You are #45,231 in line. Estimated wait: 4 min 30 sec"
5. Client polls every 5 seconds: GET /queue/status?session=<id>
6. Edge worker responds with updated position (positions advance as
users ahead complete or abandon)
RELEASE PHASE:
7. User reaches front of queue
8. Edge worker generates signed JWT token:
{ user_id, event_id, issued_at, expires_in: 300, nonce }
9. Client receives token → redirected to seat selection page
10. All subsequent booking API calls include X-Queue-Token header
BOOKING PHASE:
11. Reservation Service validates token: signature, expiry, event match
12. Token is marked as "consumed" after first successful reservation
13. If user doesn't complete booking within 5 minutes, token expires
→ user must re-enter queue (but gets priority re-entry)
Queue position assignment: FIFO vs randomized
How you assign queue positions has major fairness implications. Pure FIFO rewards users who arrive first — which means bots with faster network connections always win. Randomized assignment at sale start levels the playing field.
| Strategy | How it works | Fairness | Verdict |
|---|---|---|---|
| ❌ Pure FIFO (arrival order) | First request gets position 1, second gets position 2, etc. | Terrible. Bots with sub-millisecond response times always get front positions. Users on slow connections are permanently disadvantaged. | Rewards automation over humans. Bots dominate the front of every queue. |
| ⚠️ FIFO with pre-sale window | Users register interest days before. At sale time, registered users get random positions; latecomers go to the back. | Better — registration proves intent. But bots can register thousands of accounts. | Improved but still gameable. Requires identity verification to prevent mass bot registration. |
| ✅ Randomized at sale start | All users who arrive within the first 30 seconds of sale get randomly shuffled positions. After 30s, new arrivals go to the back (FIFO). | Excellent. Arriving at 0:01 vs 0:29 gives equal chance. Eliminates the bot speed advantage during the critical window. | Production choice (Ticketmaster Smart Queue). Fair for humans, frustrating for bots. |
The randomized approach works because the first 30 seconds is when the thundering herd arrives. By shuffling everyone who arrives in that window, you eliminate the advantage of being 50ms faster. After the initial window closes, FIFO is fine because the rush is over.
Release rate calculation
The waiting room must release users at exactly the rate the booking service can handle. Too fast → backend overload. Too slow → unnecessary wait times and abandoned users.
Static calculation:
Booking service capacity: 50K reservations/sec
Average user session: 45 seconds (select seats + checkout)
Concurrent booking sessions: 50K × 45s = 2.25M users in booking flow
But we don't want 2.25M concurrent — that's the max before degradation.
Target: 70% utilization = ~1.5M concurrent booking sessions
Release rate = (target_concurrent × completion_rate) / avg_session_time
Release rate = (1.5M × 0.6) / 45s = 20K users/sec released from queue
Adaptive adjustment (feedback loop):
Every 10 seconds, the waiting room checks:
- Current booking service latency (p99)
- Current Redis operation latency
- Current payment gateway success rate
If p99 latency > 400ms → reduce release rate by 20%
If p99 latency < 200ms → increase release rate by 10%
If payment gateway errors > 5% → pause releases for 30 seconds
This feedback loop prevents the waiting room from overwhelming
backends even if capacity changes mid-sale (server failure, etc.)
Example timeline (Taylor Swift sale):
T+0s: 3.5M users arrive. All shuffled into queue.
T+0-30s: Randomization window. No releases yet.
T+30s: Begin releasing at 20K users/sec
T+60s: 600K users have entered booking flow
T+120s: Feedback loop detects p99 at 350ms → reduces to 16K/sec
T+180s: First users completing purchases. Seats selling fast.
T+300s: Event sold out. Queue closed. Remaining users notified.
Edge implementation details
The waiting room must handle 3.5M concurrent polling connections without itself becoming a bottleneck. Running it at the CDN edge (not origin) is critical — edge workers are distributed across hundreds of PoPs globally and scale to millions of concurrent requests automatically.
Queue state storage:
Option A: Redis cluster (centralized, strongly consistent positions)
Option B: Durable Objects (Cloudflare) — per-event state at edge
Option C: DynamoDB with DAX cache — serverless, auto-scaling
Production choice: Redis cluster for position tracking +
edge KV store for queue configuration (release rate, status)
Polling optimization:
3.5M users polling every 5 seconds = 700K requests/sec to queue service
Optimization 1: Long-polling (hold connection for up to 30s, respond
immediately when position changes significantly)
→ Reduces polling to ~120K req/sec (6× reduction)
Optimization 2: Batch position updates
→ Don't update position on every single departure
→ Update in batches of 100 (position changes from 45231 to 45131)
→ Users don't need per-person granularity
Optimization 3: Estimated wait time is computed client-side
→ Server sends: { position, release_rate, avg_session_time }
→ Client computes: position / release_rate = estimated_wait
→ No server-side computation per poll
Token security:
- HMAC-SHA256 signed with per-event rotating secret
- Contains: user_id, event_id, issued_at, nonce
- Single-use: nonce is stored in Redis SET, checked on first use
- Expires in 5 minutes if unused
- Cannot be transferred (bound to user_id from auth session)
🔑 What happens when users abandon the queue?
Not everyone who enters the queue will wait. Abandonment rate is typically 30-50% for waits over 10 minutes. The system handles this naturally: abandoned users never claim their token, so their position is simply skipped. The release rate stays constant — if user #45231 abandons, user #45232 gets released instead. No explicit "remove from queue" action is needed. The queue is a logical construct, not a physical data structure that needs compaction.
💡 Interview tip
The waiting room is often the "aha moment" in this interview. Many candidates jump straight to distributed locking without addressing HOW 3.5M users reach the locking layer. The senior answer: "They don't. The waiting room ensures only 20K users/sec ever reach the booking service. The booking service is always operating within capacity. The hard problem isn't handling 3.5M concurrent reservations — it's ensuring only 20K/sec actually attempt one."
Payment Saga
The booking flow spans multiple services (Inventory, Payment, Notification) and an external payment gateway. A traditional database transaction can't span these boundaries. Instead, we use a — a sequence of steps where each has a compensating (rollback) action. If any step fails, previous steps are undone. The Reservation Service acts as the saga orchestrator, coordinating the entire flow.
Saga steps: Reserve → Charge → Confirm → Notify
Each step in the saga is idempotent (safe to retry) and has a defined compensating action. The orchestrator tracks the saga state and triggers compensation on failure.
HAPPY PATH (all steps succeed):
Step 1: HOLD SEATS (Inventory Service)
Action: Redis SETNX — atomically hold requested seats
Success: Seats marked as HELD with 10-min TTL
Duration: <5ms
Step 2: CREATE PAYMENT ORDER (Payment Service)
Action: Create payment record in DB, call payment gateway
Success: Payment intent created, gateway returns "processing"
Duration: 200-500ms (external API call)
Step 3: AWAIT PAYMENT CONFIRMATION (async)
Action: Wait for payment gateway webhook (success/failure)
Success: Webhook confirms charge captured
Duration: 1-30 seconds (depends on gateway, 3D Secure, etc.)
Step 4: CONFIRM BOOKING (Reservation Service)
Action: Transition seats HELD → SOLD (remove TTL)
Write booking record to Postgres
Success: Seats permanently sold, booking confirmed
Duration: <50ms
Step 5: SEND CONFIRMATION (Notification Service)
Action: Email confirmation + e-ticket PDF, push notification
Success: User notified (async, non-blocking)
Duration: Async (doesn't block the saga)
─────────────────────────────────────────────────────────────────
COMPENSATION (rollback on failure):
If Step 2 fails (payment gateway error):
Compensate Step 1: Release seat holds (DELETE Redis keys)
→ Seats immediately available for other users
If Step 3 fails (payment declined):
Compensate Step 1: Release seat holds
Compensate Step 2: Cancel payment intent (if created)
→ User sees "Payment declined. Seats released."
If Step 4 fails (Postgres write fails):
Compensate Step 3: Refund the charge (payment was captured)
Compensate Step 1: Release seat holds
→ User gets refund, seats released. Retry from scratch.
If Step 5 fails (notification service down):
NO COMPENSATION. Booking is confirmed. Retry notification later.
→ Notification failure never rolls back a successful booking.
Orchestration vs choreography (bad → good → optimal)
There are two ways to coordinate a saga: choreography (each service reacts to events) or orchestration (a central coordinator drives the flow). For ticket booking, the choice is clear:
| Approach | How it works | Problem for ticket booking | Verdict |
|---|---|---|---|
| ❌ Choreography (event-driven) | Each service publishes events. Next service subscribes and reacts. No central coordinator. | Hard to reason about: 'did the payment succeed before the hold expired?' Compensation logic scattered across services. Debugging is a nightmare. | Too complex for a multi-step flow with time constraints (10-min hold). Impossible to implement hold extension without a coordinator. |
| ⚠️ Choreography with process manager | Event-driven but with a stateful process manager tracking saga state. | Better visibility, but the process manager IS an orchestrator with extra steps. You've reinvented orchestration with more complexity. | Technically works but adds unnecessary indirection. If you need a coordinator, just use orchestration. |
| ✅ Orchestration (Reservation Service) | Reservation Service explicitly calls each step in sequence. Tracks state. Triggers compensation on failure. | None. Clear flow, easy to debug, easy to add hold extensions, easy to implement timeout logic. | Production choice. The Reservation Service owns the saga lifecycle. State is explicit and auditable. |
Handling payment gateway timeouts
The payment gateway is the most unreliable component in the flow. It's external, has variable latency (1-30 seconds), and can fail in ambiguous ways (timeout ≠ failure — the charge might have succeeded). The saga must handle every possible payment outcome:
Payment outcomes and saga responses:
1. INSTANT SUCCESS (gateway returns "captured" synchronously)
→ Proceed to Step 4 (confirm booking) immediately
→ Rare with modern gateways (most are async)
2. PROCESSING (gateway returns "pending", will send webhook)
→ Wait for webhook. Set internal timeout = hold_expiry - 30 seconds.
→ If webhook arrives with "captured" → confirm booking
→ If webhook arrives with "failed" → release seats, notify user
3. TIMEOUT (no response from gateway within 10 seconds)
→ DO NOT assume failure. The charge might have succeeded.
→ Query gateway: GET /payments/{id}/status (idempotent read)
→ If "captured" → confirm booking
→ If "failed" → release seats
→ If "pending" → wait for webhook (extend hold if needed)
→ If gateway is unreachable → extend hold, retry query in 30 seconds
4. GATEWAY ERROR (5xx response)
→ Retry with exponential backoff (max 3 attempts)
→ If all retries fail → release seats, show "payment service unavailable"
→ DO NOT charge the user. Idempotency key prevents double-charge on retry.
5. WEBHOOK ARRIVES AFTER HOLD EXPIRED
→ Check if seats are still held by this user (Lua script)
→ If yes (hold was extended) → confirm booking
→ If no (hold expired, seats re-held by someone else) → REFUND
→ This is the critical race condition. Refund is the safe default.
Idempotency guarantee:
Every payment attempt carries a unique idempotency key.
If the same key is sent twice (retry after timeout):
→ Gateway returns the result of the first attempt
→ No double-charge, ever
→ Key format: "pay_{reservation_id}_{attempt_number}"
Saga state persistence
The orchestrator must persist saga state so it can recover from crashes. If the Reservation Service dies mid-saga, it must resume from where it left off — not restart from scratch (which could double-charge or double-hold).
Saga states (persisted in reservations table):
INITIATED → Saga started, no actions taken yet
SEATS_HELD → Step 1 complete. Seats held in Redis.
PAYMENT_PENDING → Step 2 complete. Payment intent created.
PAYMENT_CAPTURED → Step 3 complete. Money charged.
CONFIRMED → Step 4 complete. Booking finalized.
FAILED → Saga failed. Compensation triggered.
COMPENSATING → Compensation in progress.
COMPENSATED → All compensation complete. Clean state.
Recovery on service restart:
Query: SELECT * FROM reservations WHERE status IN
('SEATS_HELD', 'PAYMENT_PENDING', 'PAYMENT_CAPTURED', 'COMPENSATING')
For each incomplete saga:
- SEATS_HELD (no payment started): check if hold still valid
→ If hold expired: mark FAILED, no compensation needed
→ If hold valid: resume from Step 2 (create payment)
- PAYMENT_PENDING (waiting for webhook): query gateway status
→ If captured: proceed to Step 4
→ If failed: compensate (release seats)
→ If still pending: wait (webhook will arrive)
- PAYMENT_CAPTURED (booking not yet written): proceed to Step 4
→ Idempotent: if booking already exists, skip
- COMPENSATING (compensation in progress): retry compensation
→ Each compensation step is idempotent (safe to retry)
🔑 Why not two-phase commit (2PC)?
2PC requires all participants to hold locks until the coordinator says "commit." In our case, the payment gateway is an external service that doesn't support 2PC. Even if it did, holding a database lock for 5-30 seconds (payment processing time) would destroy throughput. Sagas trade strong consistency for availability — we accept that there's a brief window where the system is in an intermediate state (seats held, payment processing). The compensation mechanism ensures we always reach a consistent final state.
💡 Interview tip
The strongest answer explicitly names the failure modes: "There are exactly 3 dangerous scenarios: (1) payment succeeds but hold expired — refund automatically. (2) Payment gateway timeout — query status, don't assume failure. (3) Service crash mid-saga — recover from persisted state on restart. Each has a deterministic resolution. The system always converges to either CONFIRMED or COMPENSATED — never stuck in limbo."
Real-Time Seat Availability
During an on-sale event, 500,000 users are staring at the same seat map. When a seat gets held or sold, every other user needs to see it turn from green to gray within seconds — otherwise they waste time clicking unavailable seats and get frustrated 409 errors. The challenge: pushing real-time updates to half a million concurrent viewers without overwhelming the system.
Update Strategy: Bad → Good → Optimal
Bad: Client polls full seat map every second
Each client fetches the entire seat map (70,000 seats) every second. Simple to implement, catastrophic at scale.
500,000 clients × 1 request/sec = 500,000 req/sec (just for seat map!)
Each response: 70,000 seats × 50 bytes = 3.5 MB per response
Total bandwidth: 500,000 × 3.5 MB = 1.75 TB/sec
Problems:
→ 1.75 TB/sec egress is physically impossible
→ Redis handles 100K ops/sec, not 500K HGETALL on 70K-key hashes
→ 99% of the data hasn't changed between polls
→ Client must diff 70K seats locally to find changes
→ Battery drain on mobile devices
Good: Section-level polling with ETags
Instead of polling the full map, clients only poll the section they're viewing. Use so unchanged sections return 304 with no body.
// Client polls only their current section every 2 seconds
// GET /api/v1/events/:eventId/seats?section=Floor_A
// Headers: If-None-Match: "etag_abc123"
// Server side:
async function getSectionAvailability(eventId: string, section: string, clientEtag: string) {
const sectionData = await redis.hgetall(`event:${eventId}:section:${section}`);
const currentEtag = hashContent(sectionData);
if (currentEtag === clientEtag) {
return { status: 304 }; // Nothing changed — no body sent
}
return { status: 200, etag: currentEtag, data: sectionData };
}
// Math improvement:
// 500K users across ~10 sections = 50K users per section
// 50K × 0.5 req/sec (every 2s) = 25K req/sec per section
// 304 responses: ~200 bytes (headers only)
// 200 responses: ~50KB (section with 1000 seats)
// If 5% of polls see changes: 25K × 0.05 × 50KB = 62.5 MB/sec (manageable)
// Limitation: still 250K req/sec total across all sections
// Each request is a Redis read even for 304 (must compute ETag)
Optimal: Server-push deltas via SSE with fan-out
Instead of clients pulling, the server pushes only the seats that changed. When a seat transitions (available → held, held → sold, held → available), the system publishes a delta event to all connected clients viewing that section. Only changed seats are transmitted — typically 1-10 seats per update, not 70,000.
// When a seat state changes (in the Lua claim script or expiration worker):
async function publishSeatChange(eventId: string, seatId: string, newStatus: string) {
const change = JSON.stringify({ seatId, status: newStatus, ts: Date.now() });
// Publish to section-specific channel
const section = await getSeatSection(eventId, seatId);
await redis.publish(`seat_updates:${eventId}:${section}`, change);
}
// SSE endpoint — one long-lived connection per client
// GET /api/v1/events/:eventId/seats/stream?section=Floor_A
export async function GET(request: Request) {
const { eventId, section } = parseParams(request);
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
// Subscribe to Redis Pub/Sub for this section
const subscriber = redis.duplicate();
subscriber.subscribe(`seat_updates:${eventId}:${section}`);
subscriber.on('message', (channel, message) => {
controller.enqueue(
encoder.encode(`data: ${message}
`)
);
});
// Send initial full state on connect
const initialState = await getFullSectionState(eventId, section);
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'full', seats: initialState })}
`)
);
}
});
return new Response(stream, {
headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' }
});
}
// Bandwidth math:
// Each delta: ~100 bytes (one seat change)
// During peak: 8000 seat changes/sec across all sections
// Per section (10 sections): 800 changes/sec × 100 bytes = 80 KB/sec per client
// 50K clients per section: 80 KB × 50K = 4 GB/sec (still too much for direct push!)
// Solution: batch deltas every 500ms → 400 changes batched = 40KB per push
// 50K × 40KB / 2 (every 500ms) = 1 GB/sec — need fan-out layer
Fan-Out Architecture for 500K Connections
A single server can hold ~10,000 SSE connections. For 500,000 concurrent viewers, you need 50+ SSE servers. Redis Pub/Sub delivers each seat change to every SSE server, which then fans out to its connected clients. This is the pattern applied to real-time seat updates.
┌─────────────────┐
│ Booking Service │ (seat state changes)
└────────┬────────┘
│ PUBLISH seat_updates:{event}:{section}
▼
┌─────────────────┐
│ Redis Pub/Sub │
└────────┬────────┘
│ Delivered to ALL subscribers
┌──────────────┼──────────────┐
▼ ▼ ▼
┌────────────┐ ┌────────────┐ ┌────────────┐
│ SSE Server │ │ SSE Server │ │ SSE Server │ (50+ instances)
│ 10K conns │ │ 10K conns │ │ 10K conns │
└────────────┘ └────────────┘ └────────────┘
│ │ │
10K clients 10K clients 10K clients
Optimization: batch + compress
- SSE servers buffer changes for 500ms, then push one batched update
- Reduces per-client pushes from 800/sec to 2/sec
- Each batched push: ~2-5 KB (gzipped list of changed seats)
- Total egress: 500K × 5KB × 2/sec = 5 GB/sec (achievable with CDN-backed SSE)
| Strategy | Bandwidth (500K users) | Freshness | Complexity |
|---|---|---|---|
| Full-map polling (1s) | 1.75 TB/sec (impossible) | 1 second stale | Low |
| Section polling + ETag | ~62 MB/sec (304 optimization) | 2 seconds stale | Medium |
| SSE delta push + fan-out | ~5 GB/sec (batched, compressed) | 500ms stale | High |
📊 Graceful degradation during extreme load
When the system is under extreme pressure, degrade the real-time updates gracefully:
- Tier 1 (normal): Push every seat change within 500ms.
- Tier 2 (high load): Batch updates every 2 seconds. Show "Availability updating..." indicator.
- Tier 3 (extreme): Switch to section-level counters only ("47 seats remaining in Floor A"). No per-seat updates.
- Tier 4 (degraded): Static snapshot refreshed every 10 seconds. Accept that users will see stale data and handle conflicts at reservation time (409 response).
💡 Interviewer signal
Acknowledging that perfect real-time for 500K users is expensive and proposing graceful degradation tiers shows production maturity. The key insight: "It's okay for the seat map to be 2 seconds stale — the reservation endpoint is the true consistency boundary, not the UI."
Bot Prevention & Anti-Scalping
Scalper bots are the defining adversarial challenge of ticket booking systems. Professional scalping operations use thousands of residential proxies, browser automation frameworks, and CAPTCHA-solving services to buy tickets faster than humans. If your system doesn't actively defend against bots, scalpers will buy 30-50% of inventory within seconds and resell at 5-10× markup. This isn't a theoretical concern — it's the primary complaint users have about platforms like Ticketmaster.
Defense Layers
No single defense stops sophisticated bots. The strategy is defense-in-depth: multiple layers that each catch a different class of attacker. A casual scripter is stopped at layer 1. A professional operation might bypass layers 1-3 but gets caught at layer 4-5.
Layer 1: Rate Limiting & Request Throttling
The first line of defense. Limit how many reservation attempts a single identity (IP, user account, device fingerprint) can make within a time window. This stops naive scripts but not distributed bot networks.
// Rate limit dimensions (all enforced simultaneously):
const RATE_LIMITS = {
// Per IP: stops single-machine scripts
perIp: { window: 60, max: 5, key: (req) => req.ip },
// Per user account: stops account-per-bot patterns
perUser: { window: 60, max: 3, key: (req) => req.userId },
// Per device fingerprint: stops headless browser farms
perDevice: { window: 60, max: 3, key: (req) => req.fingerprint },
// Per event (global): caps total reservation throughput
perEvent: { window: 1, max: 2000, key: (req) => req.eventId },
};
// Sliding window implementation in Redis
async function checkRateLimit(dimension: string, key: string, limit: RateLimit) {
const redisKey = `ratelimit:${dimension}:${key}`;
const now = Date.now();
const windowStart = now - (limit.window * 1000);
// Remove expired entries, count current window, add new entry
const result = await redis.eval(`
redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', ARGV[1])
local count = redis.call('ZCARD', KEYS[1])
if count < tonumber(ARGV[2]) then
redis.call('ZADD', KEYS[1], ARGV[3], ARGV[4])
redis.call('EXPIRE', KEYS[1], ARGV[5])
return {1, count + 1}
end
return {0, count}
`, { keys: [redisKey], args: [windowStart, limit.max, now, now, limit.window] });
return result[0] === 1; // true = allowed, false = rate limited
}
Layer 2: CAPTCHA & Proof-of-Work
Require human verification before entering the queue or making a reservation. Modern CAPTCHAs (reCAPTCHA v3, hCaptcha, Cloudflare Turnstile) assign a risk score without visible challenges for legitimate users, while forcing suspicious sessions through interactive puzzles.
async function validateHumanity(request: Request): Promise<HumanityResult> {
const captchaToken = request.headers.get('x-captcha-token');
const riskScore = await verifyCaptcha(captchaToken); // 0.0 = bot, 1.0 = human
if (riskScore >= 0.7) {
return { allowed: true, challenge: 'none' }; // Likely human — no friction
}
if (riskScore >= 0.3) {
// Suspicious — require interactive challenge
return { allowed: false, challenge: 'interactive_captcha' };
}
// Almost certainly a bot — block entirely
return { allowed: false, challenge: 'blocked', reason: 'automated_traffic' };
}
// Proof-of-work alternative (for CAPTCHA-solving services):
// Require client to solve a computational puzzle that takes ~2 seconds
// Bots can solve it, but it costs them 2 seconds per attempt
// At scale: 10,000 bots × 2 sec/attempt = dramatically reduced throughput
function generatePowChallenge(): PowChallenge {
const difficulty = 20; // bits of leading zeros required
const nonce = crypto.randomBytes(16).toString('hex');
return { nonce, difficulty, algorithm: 'sha256' };
// Client must find X where sha256(nonce + X) has 20 leading zero bits
}
Layer 3: Device Fingerprinting & Behavioral Analysis
Collect browser and device signals that are hard for bots to fake: canvas fingerprint, WebGL renderer, installed fonts, mouse movement patterns, typing cadence, scroll behavior. Legitimate users have natural, varied behavior. Bots have mechanical, repetitive patterns.
// Signals collected client-side and sent with queue join request:
interface BehavioralSignals {
// Device fingerprint (hard to fake across sessions)
canvasHash: string;
webglRenderer: string;
timezone: string;
languages: string[];
screenResolution: string;
// Behavioral patterns (impossible to fake at scale)
mouseMovements: number; // humans: 50-200 movements before clicking
timeSincePageLoad: number; // humans: 5-30 seconds of browsing
scrollEvents: number; // humans: scroll to find their section
keystrokeTimings: number[]; // humans: variable inter-key delays
// Session history
pagesVisited: number; // humans: browse event page, check dates
previousPurchases: number; // returning customers are lower risk
}
// Server-side scoring
function calculateBotScore(signals: BehavioralSignals): number {
let score = 0;
// Red flags (each adds to bot probability)
if (signals.mouseMovements < 5) score += 30; // no mouse = headless
if (signals.timeSincePageLoad < 1000) score += 25; // instant click = script
if (signals.scrollEvents === 0) score += 15; // no scroll = direct API
if (signals.keystrokeTimings.length === 0) score += 10;
// Green flags (reduce bot probability)
if (signals.previousPurchases > 0) score -= 20; // returning customer
if (signals.pagesVisited > 3) score -= 10; // browsed naturally
return Math.max(0, Math.min(100, score)); // 0 = human, 100 = bot
}
Layer 4: Purchase Velocity & Pattern Detection
Even if a bot passes all previous layers, its purchasing pattern reveals it. A single identity buying 50 tickets across 10 events in one day is not a human. Cross-reference purchases across accounts that share device fingerprints, payment methods, or shipping addresses.
// Post-purchase analysis (async — doesn't block the booking flow)
async function detectScalperPatterns(booking: Booking) {
const signals = await gatherCrossAccountSignals(booking);
const rules = [
// Same device fingerprint across multiple accounts
signals.accountsWithSameDevice > 2,
// Same payment method across multiple accounts
signals.accountsWithSamePayment > 1,
// Excessive purchases for one event (per account)
signals.ticketsForThisEvent > 6,
// High purchase velocity across events
signals.purchasesLast24h > 20,
// Known scalper shipping address
signals.addressMatchesKnownScalper,
];
const triggeredRules = rules.filter(Boolean).length;
if (triggeredRules >= 3) {
// High confidence scalper — flag for review, potentially cancel
await flagForReview(booking, 'scalper_pattern', signals);
} else if (triggeredRules >= 2) {
// Suspicious — add to watchlist, require ID verification for future purchases
await addToWatchlist(booking.userId, signals);
}
}
Layer 5: Verified Fan Programs
The nuclear option: require identity verification before allowing access to high-demand events. Ticketmaster's "Verified Fan" program requires users to register days in advance, verify their identity, and link a real payment method. This dramatically reduces bot effectiveness because creating verified identities at scale is expensive.
Effective anti-bot measures
- ✅Multi-dimensional rate limiting (IP + user + device + event)
- ✅Risk-based CAPTCHA with invisible scoring for legitimate users
- ✅Proof-of-work challenges that cost bots 2+ seconds per attempt
- ✅Behavioral analysis (mouse movements, time-on-page, scroll patterns)
- ✅Queue token binding to device fingerprint (non-transferable)
- ✅Post-purchase velocity analysis across linked accounts
- ✅Verified Fan pre-registration for high-demand events
Anti-patterns that hurt legitimate users
- ❌Blocking entire IP ranges (catches users behind corporate NATs)
- ❌Aggressive CAPTCHAs on every page load (accessibility nightmare)
- ❌Requiring phone verification for every purchase (friction kills conversion)
- ❌Banning accounts without appeal process (false positives are inevitable)
- ❌Relying solely on CAPTCHA (solving services cost $2-3 per 1000 solves)
🤖 The arms race reality
Bot prevention is an ongoing arms race, not a solved problem. Professional scalping operations invest $50K-100K in infrastructure (residential proxies, CAPTCHA farms, browser fingerprint spoofing). No system achieves 100% bot prevention — the goal is to make scalping economically unprofitable by increasing the cost per ticket above the resale margin.
💡 Interviewer signal
Framing bot prevention as "defense-in-depth with economic deterrence" rather than "just add CAPTCHA" shows you understand the adversarial nature of the problem. Mentioning that the goal is economic (make scalping unprofitable) rather than absolute (block all bots) is the senior framing.
Scaling & Reliability
Ticket booking has the most extreme scaling profile of any system: idle 99% of the time, then 1000× spike in 10 seconds. Traditional auto-scaling (watch CPU, add instances) is useless — the spike arrives and overwhelms the system before new instances boot. The architecture must be pre-provisioned for peak or use the waiting room to shape demand to match capacity.
Pre-Provisioning Strategy
For known high-demand events (announced weeks in advance), the system pre-provisions infrastructure before the sale starts. This is not auto-scaling — it's scheduled scaling based on demand signals (queue size, social media buzz, artist popularity).
Demand signals (collected before sale):
- Pre-sale queue size: 14M users registered
- Artist tier: S-tier (Taylor Swift, BTS, Coldplay)
- Venue capacity: 70,000 seats
- Historical data: similar events sold out in 45 seconds
Pre-provisioning plan (triggered 30 min before sale):
Queue Service: 50 instances (10K SSE connections each = 500K capacity)
Booking Service: 20 instances (400 reservations/sec each = 8K total)
SSE Fan-out Servers: 50 instances (10K connections each)
Redis (event shard): 1 primary + 2 replicas (pre-warmed with seat data)
Payment Gateway: Pre-negotiate burst capacity with provider
CDN: Pre-warm event page and static assets at all edges
Scale-down plan (after sale):
- Monitor queue drain rate
- When queue < 1000 users: scale to 50% capacity
- When all seats sold: scale to minimal (just serving confirmations)
- Full scale-down within 30 minutes of sell-out
Failure Modes & Recovery
Every component will fail eventually. The question is: what happens to active bookings when it does? The system must degrade gracefully — a Redis failure shouldn't mean 70,000 users lose their holds.
Component: Redis Primary (seat inventory)
Impact: ALL reservations fail. Seat state unknown.
Detection: Health check fails within 2 seconds.
Recovery: Sentinel promotes replica within 5 seconds.
During 5-second window: booking service returns 503.
Queue pauses admission (no new users enter booking flow).
After promotion: resume from replica state (may lose last 1-2 seconds of writes).
Data loss: Async replication lag = 0-2 seconds of holds.
Affected users retry → Lua script re-claims if seat still available.
Component: Postgres (booking records)
Impact: Confirmed bookings can't be persisted. Payments succeed but no record.
Detection: Connection pool errors within 5 seconds.
Recovery: Failover to read replica (promoted to primary) within 30 seconds.
During window: payment saga pauses at CHARGE_CONFIRMED state.
Recovery worker retries booking insertion after failover.
Data loss: None — payment gateway has the charge record. Reconciliation recovers.
Component: Payment Gateway (external)
Impact: Users can't complete payment. Holds tick down.
Detection: Timeout rate > 50% within 10 seconds.
Recovery: Circuit breaker opens. New payment attempts get "try again in 60s."
Existing holds extended by 5 minutes automatically.
If gateway stays down > 10 min: offer alternative payment method.
Data loss: None — holds preserved, users can retry when gateway recovers.
Component: Queue Service
Impact: New users can't join queue. Existing queue positions preserved in Redis.
Detection: Health check + SSE connection drops.
Recovery: Stateless service — new instances pick up queue state from Redis.
SSE clients auto-reconnect and get current position.
Data loss: None — queue state is in Redis, not in the service.
Circuit Breaker Pattern
When a downstream dependency starts failing, continuing to send requests makes things worse (connection pool exhaustion, cascading timeouts). A stops the bleeding by failing fast when a dependency is unhealthy.
class PaymentCircuitBreaker {
private state: 'closed' | 'open' | 'half_open' = 'closed';
private failureCount = 0;
private lastFailureTime = 0;
private readonly failureThreshold = 5; // open after 5 failures
private readonly recoveryTimeout = 30000; // try again after 30s
async execute(fn: () => Promise<PaymentResult>): Promise<PaymentResult> {
if (this.state === 'open') {
if (Date.now() - this.lastFailureTime > this.recoveryTimeout) {
this.state = 'half_open'; // Allow one test request
} else {
// Fast-fail: don't even try
throw new CircuitOpenError('Payment gateway unavailable, retry in 30s');
}
}
try {
const result = await fn();
this.onSuccess();
return result;
} catch (error) {
this.onFailure();
throw error;
}
}
private onSuccess() {
this.failureCount = 0;
this.state = 'closed';
}
private onFailure() {
this.failureCount++;
this.lastFailureTime = Date.now();
if (this.failureCount >= this.failureThreshold) {
this.state = 'open';
// Extend all active holds by 5 minutes
extendAllActiveHolds(300);
}
}
}
Idempotency Across the Stack
In a system where retries happen at every layer (client retries, load balancer retries, service retries), every operation must be idempotent. Without idempotency, a single network hiccup can cause double-bookings or double-charges.
| Operation | Idempotency Key | Dedup Strategy |
|---|---|---|
| Join queue | userId + eventId | ZADD is naturally idempotent (same score overwrites) |
| Reserve seats | Client-generated UUID | Check reservations table for existing key before processing |
| Charge payment | orderId (server-generated) | Gateway deduplicates by idempotency key header |
| Confirm booking | reservationId | Lua script checks if already 'sold' before transitioning |
| Release hold | reservationId + seatId | Lua script checks if still 'held' by this reservation |
📊 Monitoring during on-sale events
Critical metrics to watch in real-time during a sale:
- Queue drain rate — users/sec entering booking flow. Should match admission controller target.
- Reservation success rate — should be >80%. If dropping, seats are being claimed faster than expected.
- Redis command latency p99 — should be <1ms. If spiking, Redis is overloaded.
- Payment gateway error rate — if >5%, circuit breaker should open.
- Hold expiration rate — high rate means users are abandoning. Consider extending hold or simplifying checkout.
- Seats remaining — when <100, prepare for "sold out" announcement.
💡 Interviewer signal
Mentioning pre-provisioning (not auto-scaling) for known spikes shows you understand that auto-scaling has a cold-start problem. "We know the spike is coming because users register for the queue days in advance — we use queue size as the scaling signal and pre-provision 30 minutes before sale" is a production insight that impresses.
Trade-offs
Every architectural decision in a ticket booking system involves trade-offs. Interviewers don't expect a perfect system — they expect you to articulate what you're gaining and what you're sacrificing with each choice. The ability to reason about trade-offs under constraints is what separates senior engineers from those who memorize architectures.
Consistency vs availability
| Decision | We chose | We sacrifice | Why it's worth it |
|---|---|---|---|
| Seat reservation | Strong consistency (Redis SETNX — exactly one winner) | Availability during Redis failover (5-15s of affected seats unreservable) | Double-booking is unacceptable. A 15-second partial outage is far better than selling the same seat twice. |
| Seat map display | Eventual consistency (2-second CDN cache) | Real-time accuracy (user might see stale availability) | 3.5M users polling in real-time would overwhelm origin. 2-second staleness is invisible to users. The reservation endpoint is the real gatekeeper. |
| Booking confirmation | Eventual consistency (async write to Postgres after Redis confirm) | Immediate queryability (booking might not appear in history for 1-2 seconds) | The user gets instant confirmation from Redis. Postgres write can lag slightly without impacting UX. |
Fairness vs throughput
| Decision | We chose | We sacrifice | Why it's worth it |
|---|---|---|---|
| Queue position assignment | Randomized in first 30 seconds (fair for humans) | Throughput during initial window (30s delay before first release) | Without randomization, bots with faster connections always win. 30-second delay is acceptable for a fair sale. |
| Release rate from queue | Metered release (20K users/sec) | Speed of sale completion (could sell faster without metering) | Unmetered release would overwhelm the booking service. Controlled flow ensures every released user has a good experience. |
| Max tickets per user | 6 tickets per account per event | Revenue from power buyers (scalpers would buy 100+) | Fairness for fans outweighs revenue from scalpers. Platform reputation depends on fans getting tickets. |
Simplicity vs resilience
| Decision | We chose | We sacrifice | Why it's worth it |
|---|---|---|---|
| Hold expiration mechanism | Redis TTL (automatic, zero-ops) | Precision (TTL can be off by ~1 second due to Redis expiry cycle) | 1-second imprecision is irrelevant. The alternative (cron jobs, delayed queues) adds operational complexity and failure modes for zero user benefit. |
| Seat state in Redis vs DB | Redis as coordination layer, Postgres as record | Single source of truth (two systems can theoretically diverge) | Redis gives 100K+ ops/sec for the hot path. Postgres UNIQUE constraint is the safety net. Divergence is self-healing (Redis TTL expires, Postgres is authoritative). |
| Saga orchestration | Explicit orchestrator (Reservation Service) | Loose coupling (services are coupled to the orchestrator) | Ticket booking has strict time constraints (10-min hold). Choreography can't enforce timeouts or extend holds. Explicit orchestration is debuggable and auditable. |
Cost vs performance
| Decision | We chose | We sacrifice | Why it's worth it |
|---|---|---|---|
| Seat map delivery | CDN with short-poll (2s TTL) | True real-time updates (WebSocket would be instant) | 3.5M WebSocket connections = 7000 servers. CDN short-poll costs pennies. 2-second delay is imperceptible to users. |
| Waiting room at edge | CDN edge workers (Cloudflare/Lambda@Edge) | Full control over queue logic (edge has limited compute) | Edge absorbs 3.5M connections without origin load. Queue logic is simple enough for edge workers. Origin only handles 20K/sec booking traffic. |
| Pre-provisioned vs auto-scaled | Pre-provision for known sales + waiting room as buffer | Cost efficiency during idle periods | Auto-scaling is too slow for ticket sale spikes (60s to scale vs 5s spike). Pre-provision for known high-demand events. Waiting room handles unexpected spikes. |
🔑 The meta trade-off
The overarching trade-off in this system is between user experience and system correctness. We could make the system faster by relaxing consistency (optimistic seat display, no hold mechanism, charge-then-check). But every relaxation creates a scenario where a user is disappointed — they thought they had a seat but didn't, or they were charged for a seat that was already sold. The 10-minute hold, the waiting room, the saga pattern — they all exist to ensure that when a user sees "booking confirmed," it's actually confirmed. No surprises.
💡 Interview tip
When discussing trade-offs, use this framework: "We chose X over Y because [specific constraint]. The cost is [what we lose]. We mitigate that cost by [compensating mechanism]. If the constraint changed (e.g., lower demand), we'd reconsider." This shows you're not dogmatic — you made a reasoned choice for this specific problem.
Follow-ups & Common Traps
These are the curveball questions interviewers throw after you present your design. They test whether you truly understand the system or just memorized an architecture. Each question probes a specific edge case, scaling challenge, or design assumption. Practice answering these out loud — the best answers are concise (30-60 seconds) and reference specific components from your design.
Scaling & performance follow-ups
Q:What if a single event has 500K seats (stadium + overflow screens)? Does your Redis approach still work?
A: Yes. 500K seats × 50 bytes = 25MB in Redis — trivial. The Lua scripts still execute atomically. The bottleneck shifts to the waiting room release rate: with 500K seats and 10-min holds, you can release ~50K users/min. For 10M interested users, that's a 200-minute queue. You'd need to shorten hold duration (5 min) or increase parallelism (partition by section, each section on its own shard with independent Lua scripts).
Q:How would you handle a flash sale where ALL seats are the same price (general admission with assigned seating)?
A: When all seats are equivalent, 'best available' becomes the only selection mode. The server assigns seats — users don't pick. This eliminates hot-seat contention entirely: the Lua script pops from a sorted set (ZPOPMIN) instead of SETNX on a specific key. Each pop is atomic, no contention. Throughput is limited only by Redis single-thread speed (~200K pops/sec). The waiting room still meters traffic, but the booking path is much simpler.
Q:Your Redis cluster has 6 shards. One shard is handling a Taylor Swift event and is at 90% CPU. What do you do?
A: Short-term: the waiting room's feedback loop detects increased latency and reduces release rate. Long-term: partition the event by section across multiple shards using hash tags (seat:{evt_123_secA}:id). Each section's Lua scripts run on a different shard. This spreads load across the cluster. You lose cross-section atomic reservations (can't atomically reserve seats in different sections), but that's rarely needed.
Q:How do you handle a global event with users in 50 countries? Single Redis cluster or multi-region?
A: Single-region Redis for the booking path. Seat reservation requires strong consistency — you can't have two regions both claiming the same seat. The waiting room runs at CDN edge (multi-region), absorbing global traffic. Users experience latency to the booking region (50-200ms depending on distance), but the actual SETNX is <1ms. Total booking latency: 200-400ms globally. Acceptable. Multi-region Redis with conflict resolution is overkill and introduces split-brain risk.
Failure & edge case follow-ups
Q:A user opens 50 browser tabs to hold 50 seats (bypassing the 6-seat limit). How do you prevent this?
A: The user_holds:{event_id}:{user_id} set in Redis tracks all seats held by a user. The Lua script checks SCARD before allowing a new hold. 50 tabs all hit the same user_id — only the first 6 succeed, the rest get MAX_SEATS_EXCEEDED. The check is inside the atomic Lua script, so even simultaneous requests from 50 tabs can't race past the limit. Additionally, the queue token is single-use — you can't enter the booking flow 50 times with one token.
Q:What happens if your seat availability cache (CDN) is 3 seconds stale and a user clicks a seat that's actually taken?
A: The user gets a 409 Conflict response with 'SEAT_UNAVAILABLE' and a suggestion to try nearby seats or use best-available. This is expected behavior — the seat map is optimistic (eventually consistent), but the reservation is pessimistic (strongly consistent). The UX handles this gracefully: the seat turns red, a toast says 'This seat was just taken', and alternative seats are highlighted. It's a minor friction, not a failure.
Q:The payment gateway charges the user but your webhook endpoint is down. The user is charged but has no ticket. What happens?
A: The saga state is PAYMENT_PENDING (we initiated the charge). When our webhook endpoint recovers, the gateway will retry delivery (most gateways retry for 24-72 hours with exponential backoff). Alternatively, we have a reconciliation job that queries the gateway every 5 minutes for pending payments: GET /payments?status=captured&created_after=<last_check>. Any captured payment without a corresponding CONFIRMED booking triggers the confirm flow. The user's seats are still held (TTL extended during payment processing).
Q:Two users click the same seat at the exact same millisecond. Who wins?
A: Redis is single-threaded. Even if two SETNX commands arrive at the 'same time', Redis processes them sequentially. The first one processed wins (SET succeeds), the second fails (key already exists). There is no tie. The 'winner' is determined by which TCP packet Redis's event loop reads first — effectively random at the millisecond level. This is fair: neither user has a systematic advantage.
Q:A user's hold expires at 10:00:00. Their payment webhook arrives at 10:00:01. Do they get the ticket?
A: No. The confirm-sale Lua script checks if the seat key still exists AND the reservation_id matches. If the TTL expired (key deleted), the script returns HOLD_EXPIRED. The payment is automatically refunded. This is why we implement hold extension: if payment is actively processing at T+9:30, we extend the TTL by 3 minutes. The 1-second race only happens if the extension mechanism also failed — extremely unlikely but handled safely via refund.
Design decision follow-ups
Q:Why not use a database queue (Postgres SKIP LOCKED) instead of Redis for seat allocation?
A: Postgres SKIP LOCKED works for moderate concurrency (~5K ops/sec) but fails at Ticketmaster scale. Under 58K reservation attempts/sec, Postgres connection pools exhaust, lock contention on indexes causes latency spikes, and WAL write amplification degrades throughput. Redis SETNX handles 100K+ ops/sec with sub-ms latency because it's in-memory, single-threaded (no lock contention), and the operation is O(1). For a local theater with 200 concurrent users, Postgres is fine. For Taylor Swift, it's not.
Q:Why orchestration over choreography for the payment saga?
A: Three reasons specific to ticket booking: (1) Time constraint — the 10-minute hold creates a deadline. An orchestrator can extend the hold when payment is slow; choreography can't enforce cross-service timeouts. (2) Compensation ordering — releasing seats MUST happen before refunding payment (otherwise the refund succeeds but seats stay locked). Orchestration guarantees ordering; choreography doesn't. (3) Debuggability — when a user says 'I was charged but have no ticket', the orchestrator's saga log shows exactly which step failed and why.
Q:How would you implement a waitlist for sold-out events?
A: Redis sorted set: ZADD waitlist:{event_id} {timestamp} {user_id}. When a seat is released (refund, cancellation, hold expiry), pop the first user from the waitlist (ZPOPMIN), send them a notification with a time-limited priority token. They get 5 minutes to complete booking with that token (skips the main queue). If they don't act, pop the next user. The sorted set ensures FIFO ordering. Capacity: track how many seats are 'pending release' to avoid notifying more users than available seats.
Q:How would you support dynamic pricing (prices increase as seats sell)?
A: Store pricing rules per event: { tier: 'premium', base_price: 150, rules: [{ threshold: 80%, multiplier: 1.5 }, { threshold: 95%, multiplier: 2.0 }] }. The Inventory Service tracks sold percentage per section. When a threshold is crossed, update the event_pricing table and invalidate CDN cache. The price shown at reservation time is locked in (stored in the reservation record) — no bait-and-switch. This is how airlines and Uber work: price reflects demand in real-time.
Common traps to avoid
🚫 Trap: 'Just use a message queue for seat reservation'
Queuing reservation requests doesn't solve the concurrency problem — it just moves it. You still need to decide who gets the seat when 10K messages arrive for the same seat. The queue adds latency (message processing time) without adding correctness. Redis SETNX is already a queue of one — first writer wins, instantly. Don't add infrastructure that doesn't solve the actual problem.
🚫 Trap: 'Use WebSocket for real-time seat map'
At 3.5M concurrent users, WebSocket for seat map updates requires ~7000 servers just for connections. The seat map changes every few seconds — a 2-second CDN cache achieves nearly the same freshness at 1/1000th the cost. WebSocket is justified for the user's own booking status (1 connection per active checkout), not for broadcasting seat map state to millions.
🚫 Trap: 'Distributed lock (Redlock) for seat reservation'
Redlock solves mutual exclusion for multi-step critical sections. Seat reservation is a single atomic operation (SETNX). You don't need to "hold a lock while doing work" — the SETNX IS the work. Redlock adds 5-15ms latency, requires 5 Redis nodes, and introduces failure modes (partial acquisition, clock skew) for zero benefit. Use it for webhook deduplication or cron job coordination — not for claiming a resource.
🚫 Trap: 'Strong consistency everywhere'
Only the reservation write path needs strong consistency. The seat map, event search, user profile, and notification delivery are all fine with eventual consistency. Applying strong consistency everywhere (synchronous replication, no caching, read-after-write guarantees) would make the system 10× slower and 10× more expensive for zero user benefit on the read path.
💡 How to handle unknown follow-ups
If an interviewer asks something you haven't prepared for, use this framework: (1) Restate the constraint the question introduces. (2) Identify which component is affected. (3) Propose the simplest change that addresses it. (4) State the trade-off of your proposal. Example: "That constraint means [X]. It primarily affects the [component]. I'd handle it by [change], which trades [A] for [B]. Want me to go deeper?"
Quick Revision
Use this section for last-minute revision before an interview. It distills the entire ticket booking system design into the key decisions, numbers, and talking points you need to recall under pressure.
Key numbers
Quick Revision Cheat Sheet
Peak concurrent users (high-demand sale): ~3.5M
Seat map read QPS (peak): ~1.4M req/sec (CDN absorbs 95%)
Reservation write QPS (peak): ~58K attempts/sec
Contention ratio (popular event): 50:1 users-to-seats
Hold duration: 10 minutes (industry standard)
Redis throughput per shard: 100K+ ops/sec
Seat inventory per event in Redis: ~3.5 MB (70K seats)
Waiting room release rate: ~20K users/sec
Payment saga total latency: 1-30 seconds (gateway dependent)
CDN seat map TTL: 2 seconds
Architecture in one paragraph
A virtual waiting room at the CDN edge absorbs the traffic spike and meters users to the booking service at 20K/sec. The booking path uses Redis SETNX for atomic seat claims (sub-ms, no lock waiting). Seats are held with a 10-minute TTL that auto-expires. Payment follows a saga pattern (hold → charge → confirm) with compensation on failure. Postgres stores the permanent booking record; Redis is the real-time coordination layer. The seat map is served via CDN with 2-second TTL (eventually consistent reads, strongly consistent writes).
Core decisions to articulate
Quick Revision Cheat Sheet
Why Redis over Postgres for seat state?: 100K+ ops/sec vs 500 ops/sec under contention. Sub-ms vs 20s lock waits.
Why waiting room over auto-scaling?: Spike hits in 5 seconds. Auto-scaling takes 60-120s. Waiting room absorbs instantly at edge.
Why SETNX over distributed locks?: Seat claim is a single atomic op, not a critical section. No lock to acquire/release. Instant success/failure.
Why TTL over cron for hold expiry?: Zero-ops, guaranteed by Redis internals. No jobs to monitor, no race conditions, no cleanup.
Why saga over 2PC?: Payment gateway is external (can't participate in 2PC). Saga allows async payment with compensation.
Why CDN short-poll over WebSocket for seat map?: 3.5M WebSocket connections = 7000 servers. CDN costs pennies. 2s staleness is acceptable.
Why randomized queue over FIFO?: FIFO rewards bots with faster connections. Randomization eliminates speed advantage.
The 5 failure scenarios to know
Quick Revision Cheat Sheet
Redis node dies: Automatic failover in 5-15s. Affected seats temporarily unavailable. No double-booking.
Payment succeeds, confirmation fails: Saga retries Postgres write. Seats stay held. User sees 'processing'. Never auto-refund.
Hold expires during payment: Confirm script checks reservation_id. If mismatch → refund. Hold extension prevents this.
Payment gateway down 30s: Circuit breaker trips. Seats stay held. Queue pauses. Flush on recovery.
User abandons mid-checkout: TTL expires. Seats auto-release. No cleanup needed.
State machine (draw this first)
AVAILABLE → HELD (SETNX + TTL) → SOLD (remove TTL) → AVAILABLE (refund)
HELD → AVAILABLE (TTL expires / payment fails / user cancels)
Key insight: AVAILABLE = key doesn't exist. TTL expiry = automatic release.
Opening statement (30 seconds)
💡 Say this to start the interview
"The core challenge in ticket booking is preventing double-booking under extreme concurrency — millions of users competing for finite, non-fungible seats within a narrow time window. I'll structure my design around three key mechanisms: (1) a virtual waiting room that shapes traffic from millions down to a manageable rate, (2) Redis-based atomic seat claims that resolve contention in sub-millisecond without lock waiting, and (3) a payment saga that handles the async charge-confirm flow with automatic compensation on failure. Let me start with requirements."