Design Ticketmaster
0%
Editorial Solution
Overview
System requirements
Functional
Non-Functional
Capacity estimation
Traffic
Storage
Key Insight
API design
Search and Discovery
Booking Flow
Waiting Room
Key Design Decisions
Database design
Schema
Why PostgreSQL for the Booking Path
Storage Layer Strategy
Key Insight
High-level design
Components
Performance at Each Layer
Request flows
Seat Booking Write Flow
Virtual Waiting Room Flow
Browsing Events Flow
Real-Time Seat Map Updates
Why Controlled Drain Rate Matters
Detailed component design
Seat Lock Mechanism: The Core Challenge
Database Schema and Sharding
Bot and Scalper Prevention
Trade-offs and tech choices
Centralized vs Distributed Seat Locking
PostgreSQL vs NoSQL for Bookings
Virtual Waiting Room vs Auto-Scaling Only
Failure scenarios and bottlenecks
Payment Timeout
Redis Crash
Booking Service Crash Mid-Transaction
Database Shard Failure
Hold Expiry During Payment
Kafka Unavailability
Future improvements
Payment Saga with Compensation
Dynamic Pricing
Transfer and Resale Marketplace
Adaptive Queue Drain Rate
Table of Contents
Overview
Design a ticket marketplace where 500K fans compete for 20K seats the moment a concert goes on sale. The core challenge is ensuring exactly one buyer wins each seat while keeping the experience fast and fair.
Functional
Ticketmaster is a ticket marketplace that sells seats for live events (concerts, sports, theater) to millions of users, often under extreme demand spikes. The central engineering challenge is not CRUD operations but concurrent seat selection: when 500,000 fans rush to buy tickets the moment a Taylor Swift concert goes on sale, exactly one buyer must win each seat.
- Event browsing and search: Users search by artist, venue, city, date, or genre. Results are ranked by relevance and date. Autocomplete returns suggestions within 100ms. Each event page shows venue details, seat map, pricing tiers, and availability.
- Interactive seat map: Users view a real-time seat map showing available, held, and sold seats. Clicking a seat places a temporary hold. The map updates within 1 second when other users claim seats.
- Time-limited seat hold: When a user selects seats, the system places a 10-minute hold. During this window, no other user can claim those seats. If the user does not complete payment within 10 minutes, the hold expires and seats return to the pool.
- Payment and e-ticket: After selecting seats, the user submits payment. On success, the system confirms the booking, generates an e-ticket with a QR code, and sends a confirmation email. Payment failures release the held seats immediately.
- Virtual waiting room: For high-demand events, users enter a waiting room before ticket sales begin. The system assigns queue positions and drains users at a controlled rate to prevent stampedes.
- Bot and abuse prevention: Rate limiting per IP and per account. CAPTCHA challenges for suspicious behavior. Queue position randomization to remove "fastest click" advantage.
Non-Functional
- Strong consistency for bookings: A seat can only be sold to exactly one buyer. No double-selling under any circumstances, even during peak load. This is the hardest constraint in the system.
- High availability for browsing: Event search and seat map viewing must remain available at 99.99% uptime. Booking availability can tolerate brief queuing during flash sales.
- Flash-sale scale: Support 500,000 concurrent users during a popular event sale. Normal traffic is 50,000 concurrent. The system must handle a 10x spike without degradation beyond controlled queuing.
- Low-latency seat locking: Acquiring a seat lock must complete in under 500ms. Users clicking a seat must get immediate feedback, either "seat held for you" or "seat already taken."
Traffic
- Total scale: 100M tickets sold per month across 5,000 events. Average event has 20,000 seats. This gives roughly 3.3M tickets per day, or about 40 per second averaged out. But traffic is extremely bursty.
- Normal load: 50,000 concurrent users browsing events and seat maps. Roughly 10,000 requests per second across search, seat map loading, and bookings.
- Flash-sale spike: A major concert (Taylor Swift, Beyonce) drives 500,000 concurrent users. 200,000 requests per second during the first 5 minutes. 90% of ticket purchases happen in the opening 5-minute window.
The 10x gap between normal and flash-sale traffic is the defining characteristic. Designing for average load means the system collapses during every major sale. Designing for peak means 90% of capacity sits idle 99% of the time. The virtual waiting room bridges this gap. It caps the effective concurrency at a manageable level regardless of how many users are waiting.
Storage
- Booking record: event_id, show_id, user_id, seat list, payment reference, timestamps, status. Roughly 500 bytes per booking.
- Monthly bookings: 100M tickets at an average of 2.5 tickets per booking = 40M bookings/month. At 500 bytes each: 20GB per month, 240GB per year.
- Event and venue metadata: 5,000 events with descriptions, images, pricing tiers, seat maps. About 2MB per event = 10GB total, slow growth.
- Seat availability: 5,000 events x 20,000 seats = 100M seat records. Each record is roughly 100 bytes (show_id, seat_id, status, held_by, expiry). Total: 10GB. This is the hot dataset, fits entirely in Redis for fast locking.
- Two-year projection: About 500GB of booking data, 10GB of event metadata, 10GB of active seat state. PostgreSQL handles this comfortably on a sharded setup. No need for a data lake for the core booking path.
Key Insight
The storage math is modest. This is not a big-data problem. The challenge is throughput, not volume. 200,000 requests per second hitting the same 20,000 seat records is an extreme contention problem, not a storage problem. Every architectural decision flows from this observation.
In an interview, state this framing early: 'This is a contention problem, not a storage problem.' It signals you understand that adding disks or shards does not help when 200K users fight over 20K rows. The interviewer wants to hear you identify the bottleneck before jumping to solutions.
Search and Discovery
The seat map endpoint returns current availability. Status is one of: available, held, sold. The client renders this as an interactive map where available seats are clickable. Held and sold seats are grayed out. During flash sales, the client receives real-time WebSocket updates via Redis Pub/Sub. For normal browsing, the client falls back to polling this endpoint every 5 seconds.
Booking Flow
The hold endpoint acquires locks on the requested seats. If all seats are available, it returns a hold ID and expiry time. If any seat is already held or sold, it returns 409 with the specific seats that are unavailable. The client can then suggest nearby alternatives.
Confirm charges the payment method and converts the hold into a confirmed booking. 410 means the 10-minute window expired (the user must re-select seats). 402 means payment failed; the hold remains active so the user can retry with a different card.
Explicit hold release for users who change their mind. Releases seats immediately rather than waiting for the 10-minute TTL.
Waiting Room
When a user reaches the front of the queue, the status endpoint returns a JWT booking token. The client includes this token in the hold request header. The Booking Service rejects hold requests without a valid token during flash-sale mode.
Key Design Decisions
Separate hold and confirm: Payment takes 2-5 seconds and fails roughly 5% of the time. Combining seat selection and payment into one call would mean holding a database lock for 2-5 seconds per user, killing throughput. The two-step approach acquires the fast Redis lock (under 1ms), releases it to a background hold, and processes payment synchronously during the confirm step (the client waits for the Stripe response) while notification delivery is asynchronous via Kafka.
Error codes with actionable context: 409 returns which specific seats are taken so the client can auto-suggest alternatives. 410 returns the expired hold so the client can restart the flow. 429 (rate limited) returns a Retry-After header.
Schema
Why PostgreSQL for the Booking Path
The show_seats table is where consistency matters most. The composite primary key (show_id, seat_id) guarantees that each seat for each show has exactly one record. The status column transitions through available to held to sold, with the held_by and booking_id columns tracking ownership. PostgreSQL enforces these constraints at the database level. Even if application code has a bug, the database rejects a duplicate seat assignment.
Storage Layer Strategy
- PostgreSQL: Source of truth for all booking data. ACID transactions guarantee no double-selling. The unique constraint on show_seats is the last line of defense.
- Redis: Performance layer for seat locking. SETNX provides sub-millisecond lock acquisition. TTL handles automatic expiry. Redis is the fast path; PostgreSQL is the correctness guarantee.
- Elasticsearch: Powers event search with full-text matching, faceted filtering (by city, genre, date), and autocomplete.
- S3: Stores venue seat map images, event promotional images, and generated e-ticket PDFs.
Key Insight
Redis and PostgreSQL serve complementary roles. Redis handles the high-throughput, low-latency lock acquisition (100K+ ops/sec). PostgreSQL handles the durable, consistent booking confirmation (1K-5K transactions/sec). If Redis fails, the system falls back to PostgreSQL row locks, slower (5-10ms per lock vs. under 1ms) but still correct. This dual-layer approach means Redis crash does not cause data loss or double-selling.
Alternative safety net: Optimistic Concurrency Control. Instead of relying solely on the unique constraint, you can add a version column to show_seats. The Booking Service reads the current version when acquiring a hold, then includes WHERE version = :expected in the UPDATE. If another transaction changed the row first, the WHERE matches zero rows and the application retries or returns 409. OCC is more general-purpose (works even without unique constraints) and catches a broader class of conflicts, but adds a retry loop to the application. For Ticketmaster, the unique constraint is simpler and sufficient, OCC becomes valuable if you later add features like dynamic pricing where the version protects against stale-price bookings.
Components
- CDN (CloudFront): Serves static assets: venue seat map images, event pages, promotional banners. During flash sales, the CDN absorbs 95% of traffic (seat map images, JavaScript, CSS) so backend services only handle dynamic requests (seat availability, lock acquisition, payment).
- API Gateway: Entry point for all client requests. Handles JWT validation, rate limiting (per IP and per user), request routing to downstream services, and waiting room enforcement (rejects requests without valid queue tokens during flash-sale mode).
- Event Service: Manages event and show metadata. Reads from PostgreSQL, writes to Elasticsearch for search indexing. Low write volume, high read volume, sits behind a Redis cache with 60-second TTL.
- Search Service: Wraps Elasticsearch. Handles autocomplete, full-text search, and faceted filtering. Stateless, scales horizontally by adding instances.
- Waiting Room Service: Manages the queue during flash sales. Assigns random positions, drains at a configurable rate, issues JWT booking tokens. Backed by Redis sorted sets for position tracking. Only active during high-demand events.
- Booking Service: The core service. Acquires Redis seat locks (SETNX), creates PostgreSQL hold records, orchestrates the payment saga, and publishes booking events to Kafka. This is the most critical service and the one most likely to be a bottleneck.
The Booking, Payment, and Notification services are deliberately separated so that a Stripe latency spike does not block seat lock acquisition. This isolation prevents cascading failures during the most critical moment: the flash sale.
- Payment Service: Wraps the payment provider (Stripe, Adyen). Handles charge requests with idempotency keys, retries on transient failures, and reports success/failure back to the Booking Service. Isolated so payment provider issues do not affect seat locking.
- Notification Service: Sends confirmation emails and push notifications. Consumes events from Kafka asynchronously. Uses templates for e-tickets with QR codes. Non-critical path, failures are retried from Kafka.
Performance at Each Layer
Redis SETNX completes in under 1ms. This means a single Redis instance can handle roughly 100,000 lock attempts per second. PostgreSQL row inserts for holds take 5-10ms, giving roughly 100-200 confirmed holds per second per connection (but with connection pooling and batching, effective throughput is much higher). The CDN handles unlimited read traffic for static assets. The API Gateway rate-limits at 1,000 requests per second per user to prevent abuse.
In an interview, highlight that the waiting room is the key architectural insight. Not auto-scaling. Auto-scaling adds compute but does not solve lock contention. The waiting room converts a 500K-user stampede into a controlled 5K-per-minute flow, keeping contention at the Redis lock layer manageable.
Mid-level: identify the two-phase hold-then-pay flow and explain why Payment Service is isolated.
Senior: explain the virtual waiting room with Redis sorted sets and how it caps effective concurrency independent of total users.
Staff: discuss pre-scaling based on on_sale_at timestamps, the CDN absorbing 95% of traffic so the backend handles only dynamic seat status, and monitoring strategies (hold expiry rate, lock P99 latency) during flash sales.
Seat Booking Write Flow
- User selects seats A3 and A4 on the interactive seat map and clicks "Hold Seats."
- Client sends
POST /v1/shows/{showId}/holdwith the seat IDs and user ID. During flash-sale mode, the request includes the JWT booking token from the waiting room. - API Gateway validates the JWT, checks rate limits, and routes to the Booking Service.
- Booking Service attempts to acquire Redis locks for both seats using a Lua script that executes
SETNXfor each seat atomically. If any seat is already locked, the entire operation rolls back (releases any locks acquired in this batch) and returns 409 with the specific taken seats. - On successful lock acquisition, the Booking Service creates a hold record in PostgreSQL: inserts a row in bookings (status: held) and updates show_seats rows to status: held with the hold expiry timestamp (current time + 10 minutes).
- Returns 200 with the hold ID, held seat details, and expiry time. The client starts a visible countdown timer.
- User enters payment details and clicks "Confirm Purchase." Client sends
POST /v1/holds/{holdId}/confirmwith the payment method. - Booking Service sends a charge request to the Payment Service with an idempotency key derived from the hold ID.
- Payment Service calls Stripe. On success, returns the payment reference. On failure, returns the error (decline, timeout).
- On payment success: Booking Service updates PostgreSQL (bookings status: confirmed, show_seats status: sold), deletes the Redis lock (seat is now permanently sold), and publishes a
booking.confirmedevent to Kafka. - Notification Service consumes the Kafka event and sends the confirmation email with e-ticket.
Virtual Waiting Room Flow
- Thirty minutes before the on-sale time, the event page shows a "Join Waiting Room" button. Users click it and the client sends
POST /v1/events/{eventId}/queue. - Waiting Room Service adds the user to a Redis sorted set with a random score (not arrival time). This randomization prevents "fastest click wins" behavior.
- Returns the user's queue position and a queue token. The client displays "You are number 42,381 in line" and polls for updates every 5 seconds.
- At the on-sale time, the Waiting Room Service begins draining the queue at a configured rate, for example, 5,000 users per minute. The drain rate is tuned based on the Booking Service's capacity and the number of available seats.
- When a user's position reaches the front, the status endpoint returns a JWT booking token with a 5-minute expiry. The client transitions from the waiting room to the seat selection page.
- The user selects seats and proceeds through the normal booking flow (steps 1-11 above). The Booking Service verifies the JWT token before accepting the hold request.
- If the user does not complete a purchase within the token's 5-minute window, the token expires and the user must rejoin the queue. This prevents users from holding queue positions indefinitely without buying.
Browsing Events Flow
- User types a search query or browses a category on the home page. Client sends
GET /v1/events/search?q=taylor+swift&city=nyc. - API Gateway routes the request to the Search Service.
- Search Service queries Elasticsearch for matching events (full-text search, faceted filtering by date, city, genre). For each result, it fetches cached event metadata (venue name, thumbnail, price range) from Redis to avoid hitting PostgreSQL on every read.
- Returns a paginated list of matching events with metadata. The client renders the results page.
Real-Time Seat Map Updates
When User A locks seat A3, User B needs to see that seat disappear from the map immediately — not 5 seconds later on the next poll.
- Booking Service acquires a Redis lock for seat A3. Immediately after, it publishes a status-change event to a Redis Pub/Sub channel:
PUBLISH show:42:seats {seat: "A3", status: "held"}. - A WebSocket Gateway subscribes to the Pub/Sub channel for each show that has connected clients. When it receives the event, it pushes the delta to all connected clients viewing that show.
- The client applies the delta to its local seat map state: seat A3 transitions from "available" (green) to "held" (gray). No full-page refresh needed.
Scaling the WebSocket layer. During a flash sale, 50K-100K users actively view the seat map simultaneously (the rest are still in the waiting room). Each WebSocket Gateway instance handles roughly 10K persistent connections. Horizontal scaling to 10 instances covers the load. Sticky sessions (via client-assigned instance ID in the connection URL) ensure reconnections reach the same instance, preserving Pub/Sub subscription state.
Why Pub/Sub and not polling? During a flash sale, seats change status hundreds of times per second. Polling every 5 seconds means a user might select a seat that was taken 4.9 seconds ago, only to get a 409 error on hold. Pub/Sub delivers the change within 10-50ms, reducing wasted hold attempts and improving perceived fairness.
Graceful degradation. If the WebSocket layer is overloaded or a client falls back to HTTP (corporate firewalls blocking WebSocket), the system still works via the 5-second polling endpoint. The seat map is slightly stale but the SETNX layer still prevents double-booking. Real-time updates are a UX optimization, not a correctness requirement.
Why Controlled Drain Rate Matters
Without the waiting room, 500K users simultaneously hit the seat lock layer. Even though Redis handles 100K ops/sec, 500K concurrent lock-release-retry cycles for 20K seats creates extreme contention, most requests fail and retry, amplifying load. The waiting room converts this stampede into a steady stream of 5K users per minute. At this rate, each batch of users sees reasonable availability, completes purchases successfully, and the system operates within its design limits.
Seat Lock Mechanism: The Core Challenge
This is the heart of the Ticketmaster design and the GATE question in interviews. When 50,000 users click "Hold" on their desired seats within seconds, the system must guarantee that exactly one user wins each seat and everyone else gets an immediate "seat taken" response.
Redis SETNX as the arbitrator. Redis is single-threaded. All commands execute sequentially on one CPU core. When the Booking Service sends SETNX lock:show_42:seat_A3 user_123, Redis checks if the key exists and sets it atomically. No race condition is possible because no other command can interleave. If User A and User B both send SETNX for the same seat at the same microsecond, Redis processes one first (the one whose TCP packet arrived first), and the second gets a "key already exists" response.
Multi-seat atomic locking with Lua:
This script runs atomically on Redis. Either all seats are locked for the user, or none are. The rollback inside the script prevents partial locks that would waste seats.
Why pessimistic locking wins here. Pessimistic locking (acquire lock before proceeding) beats optimistic locking (try, detect conflict, retry) when contention is high. For a popular concert section where 1,000 users want the same 50 seats, optimistic locking means 950 users write their hold, detect the conflict on commit, and retry, generating 20x the write load. Pessimistic locking with Redis SETNX means 950 users get an instant "taken" response (under 1ms) with no wasted writes.
Redis SETNX is the perfect tool for seat locking because Redis is single-threaded. Two SETNX commands for the same seat are serialized by the event loop with zero chance of a race condition. Multi-threaded databases need SELECT FOR UPDATE to achieve the same guarantee, adding 5-10ms per lock.
Do not propose optimistic locking for high-contention seat selection. With 1,000 users targeting 50 seats, all 1,000 attempt a write but only 50 succeed. The other 950 generate wasted disk I/O for transactions that roll back on version conflict. Pessimistic locking via SETNX generates only 50 writes with 950 instant rejections at under 1ms each, a 20x reduction in write load.
Database Schema and Sharding
Sharding strategy: show_id. The shard key determines which database instance holds each record. Sharding by show_id means all show_seats records for a given show live on the same shard. This is critical because a booking transaction touches multiple show_seats rows (one per seat), with show_id sharding, this is a single-shard transaction requiring no distributed coordination.
What if you shard by user_id instead? A user buying 4 seats for show_42 would touch 4 show_seats rows. If those rows are on different shards (because the shard key is user_id, not show_id), the transaction becomes a distributed 2-phase commit across 4 shards. At 5,000 transactions per second during a flash sale, 2PC adds 10-50ms per transaction and risks coordinator failures. Show_id sharding avoids this entirely.
Hold expiry and cleanup. Redis TTL handles the fast path, when a 10-minute hold expires, the Redis key vanishes automatically. But PostgreSQL also needs cleanup. A background job runs every 60 seconds:
UPDATE show_seats SET status = 'available', held_by = NULL, hold_expires = NULL WHERE status = 'held' AND hold_expires < NOW()
This catches orphaned holds where Redis deleted the key but the PostgreSQL update failed (network issue, Booking Service crash). The dual cleanup ensures seats are never permanently stuck in "held" status.
Indexing for performance. The show_seats table needs two indexes beyond the primary key: one on (show_id, status) for the seat map query ("give me all available seats for show_42") and one on (status, hold_expires) for the cleanup job. The first index makes seat map loading fast (10ms for 20K seats). The second index makes cleanup efficient (scan only held rows with expired timestamps).
Bot and Scalper Prevention
Scalper bots are Ticketmaster's biggest operational challenge. Without defenses, bots buy 60-80% of popular event inventory within seconds, reselling at 3-5x markup. The system uses layered defenses:
Before the queue: identity verification. Accounts purchasing tickets for high-demand events must have a verified phone number and a credit card on file. Account age matters: accounts created in the last 24 hours are deprioritized in queue scoring. This raises the cost of creating bot accounts from near-zero to meaningful (each needs a unique phone number and card).
In the queue: behavioral signals. The waiting room collects passive signals while users wait. Request timing variance (bots send requests at exact intervals; humans are noisy), mouse movement and scroll patterns on the queue page (headless browsers produce none), and device fingerprinting (canvas hash, WebGL renderer, installed fonts). Each signal produces a risk score. Users scoring above 0.7 are served a CAPTCHA challenge before receiving a booking token. Users above 0.9 are silently deprioritized (moved to the back of the queue without notification).
At booking: rate and pattern limits. Per-account limits (maximum 6 tickets per event) are enforced at the Booking Service. IP-level rate limiting catches bot farms sharing an IP. Cross-account correlation detects when multiple accounts use the same device fingerprint or payment method, flagging coordinated purchasing.
Why not just CAPTCHA everything? Adding CAPTCHA to every queue entry degrades UX for the 95% of legitimate users. The layered approach applies friction proportional to suspicion: low-risk users flow through seamlessly, medium-risk users get one CAPTCHA, high-risk users are blocked. This preserves UX for real fans while making bot operation expensive.
Mid-level: explain Redis SETNX for seat locking and the Lua script for atomic multi-seat holds.
Senior: discuss show_id sharding to keep booking transactions single-shard and the dual cleanup mechanism (Redis TTL + PostgreSQL background job).
Staff: explain Redis hash tags for Cluster co-location, the pessimistic vs optimistic locking trade-off with quantified write amplification, and the 60-second cleanup job as a safety net for the rare Redis-PostgreSQL desync.
Centralized vs Distributed Seat Locking
Centralized (single Redis instance): All lock operations for a show go to one Redis instance. Atomic Lua scripts work without modification. Throughput: 100K-200K ops/sec. Latency: under 1ms. Failure mode: if the instance crashes, all locks for that show are lost (reconstructed from PostgreSQL). This is the simplest approach and works for most events.
Distributed (Redis Cluster): Locks are distributed across multiple shards using hash tags to keep per-show locks co-located. Throughput: scales horizontally. Failure mode: a single shard failure affects only the shows mapped to that shard. Complexity: requires hash tag discipline, monitoring for hot shards, and rebalancing.
Decision: Start with centralized Redis for each major event. A single Redis instance handles 100K+ ops/sec, and the waiting room caps concurrent lock attempts at a manageable level. Only move to Redis Cluster if a single event requires more than 100K concurrent lock operations, which the waiting room is specifically designed to prevent.
PostgreSQL vs NoSQL for Bookings
PostgreSQL (chosen): ACID transactions guarantee no double-selling. The unique constraint on show_seats is a hard correctness guarantee. Foreign keys maintain referential integrity. 500GB of booking data fits on a sharded PostgreSQL setup. The relational model matches the domain: events have shows, shows have seats, seats belong to bookings.
DynamoDB/Cassandra: Higher write throughput for individual operations. But ticket booking requires multi-row transactions (lock 4 seats, create booking, link seats to booking). These require coordination across partitions. DynamoDB's TransactWriteItems supports this but with a 25-item limit and higher latency than PostgreSQL transactions. Cassandra has no multi-row transactions at all.
Decision: PostgreSQL for correctness. The write volume (40M bookings per month, roughly 15 per second average) is well within PostgreSQL's capacity. The flash-sale burst (5K bookings per minute through the waiting room) is handled by Redis at the lock layer, PostgreSQL only processes confirmed bookings, which are a fraction of lock attempts.
Virtual Waiting Room vs Auto-Scaling Only
Auto-scaling only: Add Booking Service instances as traffic increases. Works for compute-bound workloads. Does not solve the fundamental bottleneck: seat lock contention. Even with 100 Booking Service instances, all lock attempts funnel through the same Redis keys. The bottleneck is at the lock layer, not the compute layer.
Virtual waiting room: Converts 500K simultaneous users into a 5K-per-minute controlled flow. The effective concurrency at the lock layer stays constant regardless of how many users are waiting. Cost: users experience a wait. Benefit: the system operates within its design limits, and the users who do reach the booking page have a smooth experience instead of a flood of timeout errors.
Decision: Both, but the waiting room is the critical piece. Auto-scaling handles the HTTP connection layer (serving 500K WebSocket connections for queue status polling). The waiting room handles the concurrency layer (capping lock attempts at a sustainable rate). Without the waiting room, auto-scaling the Booking Service to 100 instances still results in 500K concurrent lock attempts overwhelming Redis.
Quantify the contention gap when comparing waiting room vs auto-scaling. Without the waiting room, 500K users generate 1-2M lock attempts/sec against Redis capacity of 100K ops/sec (10-20x over capacity). With the waiting room capping at 5K concurrent users, lock attempts drop to 10-20K/sec, well within capacity.
Do not suggest DynamoDB or Cassandra for the booking database. Ticket booking requires multi-row ACID transactions (lock 4 seats, create booking, link seats). DynamoDB TransactWriteItems has a 25-item limit and higher latency. Cassandra has no multi-row transactions at all. PostgreSQL ACID is simpler and proven for this workload.
Mid-level: explain why the waiting room is needed (lock contention, not compute, is the bottleneck) and justify PostgreSQL over NoSQL for multi-row booking transactions.
Senior: quantify the contention gap (500K users producing 1-2M lock attempts/sec vs 100K Redis capacity), explain why centralized Redis suffices when the waiting room caps concurrency, and discuss the AOF persistence trade-off (durability vs latency).
Staff: propose adaptive drain rates that respond to real-time seat availability, design the migration path from centralized Redis to Cluster when concurrent flash sales exceed single-instance capacity, and explain how to monitor lock P99 latency as the signal for whether the waiting room drain rate is too aggressive.
Payment Timeout
The user completes seat selection, the Booking Service sends a charge request to Stripe, and Stripe does not respond within 10 seconds. The Booking Service cannot tell whether the charge succeeded or failed. It might be in-flight.
Recovery: The Booking Service does not retry immediately. It marks the hold as "payment_pending" in PostgreSQL and moves on. A reconciliation job runs every 2 minutes: for each payment_pending hold, it queries Stripe using the idempotency key. If Stripe says "succeeded," the job confirms the booking. If Stripe says "failed" or "not found," the job releases the hold. If Stripe is still unreachable, the job retries on the next cycle. The idempotency key ensures that even if the original charge did go through, querying Stripe does not create a duplicate charge.
Why not retry immediately? During a flash sale, Stripe timeouts often indicate Stripe is overloaded. Retrying immediately adds load to an already struggling payment provider. The 2-minute reconciliation interval gives Stripe time to recover.
Redis Crash
The Redis instance handling seat locks crashes and restarts with an empty dataset (no persistence enabled for lock data, RDB/AOF adds latency that is unacceptable for the lock fast path).
Recovery:
- The Booking Service detects Redis unavailability (connection timeout or error responses).
- It switches to PostgreSQL fallback mode: seat locks are acquired using
SELECT FOR UPDATEon show_seats rows. This is slower (5-10ms per lock vs. under 1ms) but correct. - Simultaneously, a recovery process scans PostgreSQL for all holds with status "held" and
hold_expires > NOW(). For each active hold, it recreates the Redis key with the remaining TTL. - When Redis is fully repopulated, the Booking Service switches back to Redis mode.
- During the PostgreSQL fallback window, throughput drops from 100K ops/sec to roughly 1K-5K ops/sec. The waiting room automatically slows its drain rate to match.
Key design principle: Redis is a performance optimization, not a correctness requirement. The system works correctly (no double-selling) at any point during the Redis failure and recovery sequence, just slower.
Interviewers frequently ask 'what if Redis dies during a flash sale?' They are testing whether Redis is a single point of failure. The correct answer: the system degrades gracefully from 100K ops/sec to 1-5K ops/sec by falling back to PostgreSQL SELECT FOR UPDATE. No data is lost and no double-selling occurs. If you designed Redis as the only source of truth with no fallback, you have a correctness gap.
Booking Service Crash Mid-Transaction
The Booking Service acquires a Redis lock and creates a PostgreSQL hold, then crashes before sending the payment response to the client.
Recovery:
- The Redis lock still has its TTL. If the user does not retry (browser closed), the TTL expires and the seat returns to the pool.
- The PostgreSQL hold has its
hold_expirestimestamp. The cleanup job marks it as expired after the time passes. - If the user retries (refreshes the page), they see their held seats (fetched from PostgreSQL) and can proceed with payment using a new Booking Service instance. The hold ID is the continuity point. The client includes it in the confirm request, and any Booking Service instance can look it up.
No data is lost and no seats are permanently locked. The worst case is a 10-minute delay (the hold TTL) before the seat returns to the pool.
Database Shard Failure
A PostgreSQL shard fails (disk corruption, network partition). All shows on that shard are affected, bookings cannot be confirmed, holds cannot be created in PostgreSQL.
Recovery:
- Synchronous replication to a standby ensures zero data loss for committed transactions. The standby is promoted to primary within 5-10 seconds.
- During the 5-10 second failover, Redis locks continue working (seats can be held) but PostgreSQL writes fail. The Booking Service queues these writes and replays them after the standby promotion.
- For longer outages, the waiting room for affected shows pauses drain and displays "Sales temporarily paused, please wait" rather than allowing users through to a broken booking flow.
Hold Expiry During Payment
The most subtle race condition in the system: User A holds seats at T=0. At T=9:50 (10 seconds before expiry), User A submits payment. At T=10:00, the Redis TTL fires and the seat locks vanish. At T=10:02, User B locks the same seats. At T=10:05, Stripe confirms User A's payment. Now both users believe they own the same seats.
Prevention: The Booking Service checks hold ownership before confirming the booking. The confirm flow is:
- Receive Stripe success webhook for hold_123.
- Begin PostgreSQL transaction.
SELECT status, held_by FROM show_seats WHERE show_id = 42 AND seat_id IN ('A3', 'A4') FOR UPDATE.- Verify that
held_bystill matches User A andstatusis stillheld. If the cleanup job already released the seats (status = available) or User B now holds them (held_by = User B), the confirmation fails. - If verification fails: roll back the transaction, issue an automatic Stripe refund via idempotency key, and notify User A that their hold expired.
Mitigation to reduce occurrence: When the Booking Service receives a payment request for a hold with less than 2 minutes remaining, it extends the Redis TTL by 5 minutes before initiating the Stripe call. This gives payment enough runway without permanently locking seats. The PostgreSQL hold_expires is also extended. If payment ultimately fails, the extended TTL still expires and the normal cleanup recovers the seats.
Kafka Unavailability
If Kafka is down, booking confirmation events are not published. Notification Service does not send emails.
Recovery: The Booking Service writes a local event log (append-only file or database table) as a fallback. When Kafka recovers, a replay job reads the local log and publishes all missed events. Bookings are still confirmed (Kafka is not in the booking critical path. It only carries post-booking events). Users receive a delayed confirmation email rather than no email.
Payment Saga with Compensation
The current design handles payment as a simple request-response. A more robust approach is a full saga pattern with explicit compensation steps. The saga coordinator tracks the booking through stages: seats_held, payment_initiated, payment_confirmed, booking_confirmed. Each stage has a compensation action: payment_confirmed rolls back to refund, seats_held rolls back to release. If the process stalls at any stage for more than the timeout, the compensation runs automatically. This handles edge cases the simple reconciliation job misses: partial charges (Stripe charged but returned an ambiguous response), network partitions between services, and multi-step bookings (hold + add-ons + payment).
Dynamic Pricing
Ticket prices currently are fixed per tier. Dynamic pricing adjusts prices based on real-time demand signals: seats selling fast in a section increase in price, slow-selling sections decrease. Implementation: a pricing engine reads the current sold/available ratio per section every 30 seconds and adjusts prices using a configured formula. The show_seats table gets a price_version column. The client displays the price with its version, and the hold request includes the version. If the price changed between display and hold, the system returns the new price for user confirmation. This prevents sticker shock (user sees $50, gets charged $75).
Transfer and Resale Marketplace
Allow ticket holders to transfer or resell tickets through the platform. A resale listing creates a new show_seats availability record linked to the original booking. Price caps prevent scalping (resale price limited to 120% of face value). The transfer flow: seller lists ticket, buyer purchases, system cancels original booking_seat and creates a new one for the buyer. The QR code on the e-ticket is regenerated to prevent the seller from using the old ticket. Blockchain-based verification is unnecessary. The platform controls the QR code validation at entry, so a simple database record suffices.
Adaptive Queue Drain Rate
The current drain rate is static (5K users/min). An adaptive system monitors real-time signals: seat availability (if seats are selling faster than expected, slow the drain to prevent empty-handed users), payment success rate (if Stripe is degraded, slow the drain to avoid timeout storms), and Redis lock P99 latency (if latency exceeds 5ms, reduce concurrent users). The drain rate controller adjusts every 30 seconds based on these signals, keeping the system within its operating envelope without manual intervention.
Try this problem yourself
Reading a solution is not the same as producing one under interview pressure. Design it on the whiteboard and get scored feedback.
0%
Editorial Solution
Overview
System requirements
Functional
Non-Functional
Capacity estimation
Traffic
Storage
Key Insight
API design
Search and Discovery
Booking Flow
Waiting Room
Key Design Decisions
Database design
Schema
Why PostgreSQL for the Booking Path
Storage Layer Strategy
Key Insight
High-level design
Components
Performance at Each Layer
Request flows
Seat Booking Write Flow
Virtual Waiting Room Flow
Browsing Events Flow
Real-Time Seat Map Updates
Why Controlled Drain Rate Matters
Detailed component design
Seat Lock Mechanism: The Core Challenge
Database Schema and Sharding
Bot and Scalper Prevention
Trade-offs and tech choices
Centralized vs Distributed Seat Locking
PostgreSQL vs NoSQL for Bookings
Virtual Waiting Room vs Auto-Scaling Only
Failure scenarios and bottlenecks
Payment Timeout
Redis Crash
Booking Service Crash Mid-Transaction
Database Shard Failure
Hold Expiry During Payment
Kafka Unavailability
Future improvements
Payment Saga with Compensation
Dynamic Pricing
Transfer and Resale Marketplace
Adaptive Queue Drain Rate