How to Design a Ticket Booking System
Building Ticketmaster, Booking.com, and Eventbrite at scale: seat selection, inventory management, and concurrent booking
Table of Contents
- Introduction — The Ticket Booking Landscape
- Functional & Non-Functional Requirements
- Capacity Estimation & Back-of-Envelope
- Data Model & Storage
- API Design
- High-Level Architecture
- Inventory Management & Seat Selection
- Concurrent Booking & Race Conditions
- Distributed Locking Strategies
- Payment Processing
- Booking Queue & Waitlist
- Flash Sale Architecture
- Caching Strategy
- Notification System
- Cancellation & Refund
- Analytics & Reporting
- Scaling the Booking System
- Monitoring & Observability
- Cost Estimation
- Case Studies — Production Systems
- Edge Cases
- Interview Q&A
- Conclusion
1. Introduction — The Ticket Booking Landscape
Ticket booking systems handle some of the most stressful workloads in the industry. When Taylor Swift's Eras Tour went on sale, Ticketmaster processed 14 million requests in a single minute and sold 2.4 million tickets in a day. When FIFA World Cup 2022 tickets were released, the platform handled 50 million concurrent users competing for 3 million seats. These systems must balance correctness (no double-booking), performance (sub-second response times), and fairness (queue-based access) under extreme load.
The core challenge of ticket booking is inventory management under concurrency. Unlike e-commerce where items can be restocked, event seats are unique and finite. A seat either exists or it doesn't — you cannot sell the same seat twice. This requires strong consistency guarantees that go beyond typical read-heavy web applications. The booking flow involves checking availability, reserving seats, processing payment, and confirming the booking — all while preventing race conditions where two users might simultaneously book the same seat.
The ticket booking industry generates over $50 billion in annual revenue globally. Ticketmaster alone processes more than 500 million tickets per year across 30+ countries. Booking.com handles 1.5 million room nights every day. The scale of these operations demands sophisticated distributed systems that can handle extreme load spikes (flash sales), maintain absolute correctness (zero double-bookings), and provide a smooth user experience (sub-second response times). The architecture must gracefully handle the transition from a quiet Tuesday morning to a Taylor Swift on-sale without manual intervention.
2. Functional & Non-Functional Requirements
Functional Requirements
| # | Requirement | Priority | Details |
|---|---|---|---|
| F1 | Browse events | Must | Search events by date, venue, artist, category |
| F2 | View seat availability | Must | Real-time seat map with status (available, held, sold) |
| F3 | Reserve seats | Must | Hold selected seats for limited time during checkout |
| F4 | Process payment | Must | Secure payment with multiple methods |
| F5 | Confirm booking | Must | Generate tickets with QR codes, send confirmation |
| F6 | Cancel/refund | Must | Cancel booking and process refund per policy |
| F7 | Seat selection UI | Should | Interactive venue map with section/row/seat |
| F8 | Waitlist | Should | Join waitlist for sold-out events |
| F9 | Dynamic pricing | Nice | Price changes based on demand |
| F10 | Group booking | Nice | Book multiple adjacent seats together |
Non-Functional Requirements
| Requirement | Target | Rationale |
|---|---|---|
| Booking latency | < 2 seconds (p99) | Users expect fast checkout |
| Availability | 99.99% | Downtime during flash sales is catastrophic |
| Double-booking prevention | Zero tolerance | Selling the same seat twice destroys trust |
| Seat hold TTL | 10 minutes | Balance between user convenience and inventory turnover |
| Flash sale throughput | 100K bookings/minute | Major event releases |
| Data consistency | Strong (ACID) | Financial transactions require strict consistency |
3. Capacity Estimation & Back-of-Envelope
Daily Volume Estimates
| Metric | Calculation | Result |
|---|---|---|
| Events per day | Given | 100,000 |
| Bookings per day | Given | 5 million |
| Seats per event (avg) | Given | 5,000 |
| Total seats available daily | 100K × 5K | 500 million |
| Booking QPS (avg) | 5M / 86,400 | ~58 QPS |
| Booking QPS (peak, 100x) | 58 × 100 | ~5,800 QPS |
| Flash sale QPS | Given (Taylor Swift scale) | ~100,000 QPS |
| Browse/search QPS | 50M pageviews / 86,400 | ~579 QPS |
| Seat map requests QPS | 10M / 86,400 | ~116 QPS |
Storage Estimates
| Data | Size per Record | Count | Total |
|---|---|---|---|
| Event metadata | ~2 KB | 1M events/year | ~2 GB/year |
| Seat inventory | ~100 bytes | 5B seats/year | ~500 GB/year |
| Bookings | ~500 bytes | 2B bookings/year | ~1 TB/year |
| Payment records | ~300 bytes | 2B records/year | ~600 GB/year |
| User sessions | ~200 bytes | 100M sessions/day | ~20 GB/day |
| Booking events (Kafka) | ~200 bytes | 5M/day | ~1 GB/day |
Bandwidth Estimates
| Operation | Requests/day | Avg Size | Daily Bandwidth |
|---|---|---|---|
| Browse events | 50M | 10 KB | ~500 GB |
| Seat map | 10M | 50 KB | ~500 GB |
| Booking requests | 5M | 2 KB | ~10 GB |
| Ticket delivery (email/QR) | 5M | 100 KB | ~500 GB |
| Total | ~1.5 TB/day |
4. Data Model & Storage
Entity Relationship
PostgreSQL Schema
SQL
CREATE TABLE events (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
venue_id BIGINT REFERENCES venues(id),
event_date TIMESTAMP NOT NULL,
category VARCHAR(100),
description TEXT,
status VARCHAR(20) DEFAULT 'active',
pricing_tiers JSONB,
max_tickets_per_user INT DEFAULT 6,
sale_start TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE seats (
id BIGSERIAL PRIMARY KEY,
event_id BIGINT REFERENCES events(id),
section VARCHAR(50) NOT NULL,
row_name VARCHAR(10) NOT NULL,
seat_number VARCHAR(10) NOT NULL,
status VARCHAR(20) DEFAULT 'available',
price DECIMAL(10,2) NOT NULL,
held_by BIGINT,
held_until TIMESTAMP,
UNIQUE (event_id, section, row_name, seat_number)
);
CREATE INDEX idx_seats_event ON seats(event_id, status);
CREATE INDEX idx_seats_held ON seats(held_by) WHERE held_by IS NOT NULL;
CREATE INDEX idx_seats_available ON seats(event_id, section)
WHERE status = 'available';
CREATE TABLE bookings (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT REFERENCES users(id),
event_id BIGINT REFERENCES events(id),
seat_ids BIGINT[] NOT NULL,
total_amount DECIMAL(10,2) NOT NULL,
status VARCHAR(20) DEFAULT 'pending',
booked_at TIMESTAMP DEFAULT NOW(),
expires_at TIMESTAMP,
cancelled_at TIMESTAMP,
refund_amount DECIMAL(10,2)
);
CREATE INDEX idx_bookings_user ON bookings(user_id, booked_at DESC);
CREATE INDEX idx_bookings_event ON bookings(event_id, status);
CREATE INDEX idx_bookings_status ON bookings(status, expires_at)
WHERE status IN ('pending', 'confirmed');
CREATE TABLE payments (
id BIGSERIAL PRIMARY KEY,
booking_id BIGINT REFERENCES bookings(id),
user_id BIGINT REFERENCES users(id),
method VARCHAR(50),
amount DECIMAL(10,2),
currency VARCHAR(3) DEFAULT 'USD',
status VARCHAR(20) DEFAULT 'pending',
transaction_id VARCHAR(255),
gateway_response JSONB,
paid_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_payments_booking ON payments(booking_id);
CREATE INDEX idx_payments_status ON payments(status, created_at);
Redis Data Structures for Real-Time Inventory
C#
// Seat availability: Redis Hash per event
// Key: event:{eventId}:seats
// Field: "{section}:{row}:{seat}" → status (available/held/sold)
// Example: event:12345:seats
// "A:1:1" → "available"
// "A:1:2" → "held:user:456:1705312800"
// "A:1:3" → "sold"
// Quick availability check
string seatKey = $"event:{eventId}:seats";
string seatField = $"{section}:{row}:{seatNumber}";
string status = await redis.HashGetAsync(seatKey, seatField);
// Count available seats per section
var allSeats = await redis.HashGetAllAsync(seatKey);
int availableCount = allSeats.Count(s =>
s.Value.ToString() == "available");
// Atomic seat hold using Lua script
string holdScript = @"
local status = redis.call('HGET', KEYS[1], ARGV[1])
if status == 'available' then
redis.call('HSET', KEYS[1], ARGV[1],
'held:' .. ARGV[2] .. ':' .. ARGV[3])
return 1
end
return 0
";
5. API Design
REST API
HTTP
// Search events
GET /api/v1/events?q=taylor+swift&city=los+angeles&date_from=2025-03-01&category=music
// Get event details
GET /api/v1/events/{event_id}
// Get seat map
GET /api/v1/events/{event_id}/seats?section=A
// Hold seats (reservation)
POST /api/v1/bookings/hold
{
"event_id": 12345,
"seats": [
{"section": "A", "row": "1", "seat": "15"},
{"section": "A", "row": "1", "seat": "16"}
]
}
// Response: { "hold_id": "h_789", "expires_at": "2025-01-15T10:40:00Z", "total": 250.00 }
// Confirm booking (with payment)
POST /api/v1/bookings/confirm
{
"hold_id": "h_789",
"payment_method": "pm_visa_4242",
"promo_code": "SAVE20"
}
// Response: { "booking_id": "b_012", "status": "confirmed", "tickets": [...] }
// Get booking details
GET /api/v1/bookings/{booking_id}
// Cancel booking
POST /api/v1/bookings/{booking_id}/cancel
{
"reason": "changed_mind"
}
// Join waitlist
POST /api/v1/events/{event_id}/waitlist
{
"preferred_sections": ["A", "B"],
"max_price": 200
}
// Get user's bookings
GET /api/v1/users/{user_id}/bookings?status=confirmed
WebSocket API for Real-Time Seat Updates
WebSocket
// Client subscribes to seat availability updates
wss://api.example.com/v1/events/{event_id}/seats
// Server pushes when seat status changes
{
"type": "seat_update",
"event_id": 12345,
"updates": [
{"section": "A", "row": "1", "seat": "15", "status": "held"},
{"section": "A", "row": "1", "seat": "16", "status": "sold"},
{"section": "B", "row": "3", "seat": "8", "status": "available"}
],
"timestamp": "2025-01-15T10:30:00Z"
}
// Server pushes booking queue position
{
"type": "queue_position",
"position": 1234,
"estimated_wait": "5 minutes",
"ahead_in_queue": 1233
}
6. High-Level Architecture
Component Responsibilities
| Component | Responsibility | Technology |
|---|---|---|
| Event Search Service | Browse events, filter, autocomplete | Elasticsearch + PostgreSQL |
| Seat Selection Service | Real-time seat map, hold seats | Redis + Lua scripts |
| Booking Service | Orchestrate booking flow, manage state | PostgreSQL + Redis |
| Payment Service | Process payments, handle refunds | PostgreSQL + Payment gateway |
| Notification Service | Email, SMS, push for confirmations | Kafka + SendGrid/Twilio |
| Queue Manager | Virtual waiting room for flash sales | Redis + custom queue |
7. Inventory Management & Seat Selection
The seat inventory is the heart of the booking system. Each seat has a lifecycle: available → held → sold (or expired → available). The challenge is managing these state transitions atomically across thousands of concurrent users without double-booking. The seat map must be real-time accurate — when one user holds a seat, all other users must see it as unavailable within milliseconds.
Seat State Machine
Atomic Seat Hold with Lua Script
The Lua script is the most critical piece of code in the entire booking system. It executes atomically on the Redis server, meaning no other client can read or modify the seat between the status check and the status update. This eliminates the race condition entirely at the storage layer. The script checks if the seat is available, and if so, marks it as held with the user ID and expiration timestamp. If the seat is already held or sold, it returns 0 (failure). This atomic guarantee is what makes Redis the ideal choice for real-time inventory management — the alternative (database locks) would reduce throughput by 100x.
C#
public class SeatInventoryService
{
private readonly IDatabase _redis;
private static readonly string HoldSeatScript = @"
local seatKey = KEYS[1]
local seatField = ARGV[1]
local userId = ARGV[2]
local holdDuration = tonumber(ARGV[3])
local currentTime = tonumber(ARGV[4])
local currentStatus = redis.call('HGET', seatKey, seatField)
if currentStatus == false or currentStatus == 'available' then
local expiresAt = currentTime + holdDuration
redis.call('HSET', seatKey, seatField,
'held:' .. userId .. ':' .. expiresAt)
redis.call('EXPIRE', seatKey, 86400)
return 1
end
-- Check if existing hold has expired
if string.sub(currentStatus, 1, 5) == 'held:' then
local parts = {}
for part in string.gmatch(currentStatus, '[^:]+') do
table.insert(parts, part)
end
local heldUntil = tonumber(parts[3])
if currentTime > heldUntil then
local expiresAt = currentTime + holdDuration
redis.call('HSET', seatKey, seatField,
'held:' .. userId .. ':' .. expiresAt)
return 1
end
end
return 0
";
public async Task<bool> HoldSeat(
long eventId, string section, string row, string seat,
long userId, TimeSpan holdDuration)
{
string key = $"event:{eventId}:seats";
string field = $"{section}:{row}:{seat}";
var result = await _redis.ScriptEvaluateAsync(
HoldSeatScript,
new RedisKey[] { key },
new RedisValue[] {
field,
userId,
(int)holdDuration.TotalSeconds,
DateTimeOffset.UtcNow.ToUnixTimeSeconds()
});
return (long)result == 1;
}
public async Task<bool> HoldMultipleSeats(
long eventId, List<(string section, string row, string seat)> seats,
long userId, TimeSpan holdDuration)
{
// Use Redis transaction to atomically hold all seats
var tx = _redis.CreateTransaction();
var tasks = new List<Task<RedisResult>>();
foreach (var seat in seats)
{
string key = $"event:{eventId}:seats";
string field = $"{seat.section}:{seat.row}:{seat.seat}";
tasks.Add(tx.ScriptEvaluateAsync(
HoldSeatScript,
new RedisKey[] { key },
new RedisValue[] {
field,
userId,
(int)holdDuration.TotalSeconds,
DateTimeOffset.UtcNow.ToUnixTimeSeconds()
}));
}
await tx.ExecuteAsync();
var results = await Task.WhenAll(tasks);
return results.All(r => (long)r == 1);
}
}
Seat Map Data Structure
C#
public class SeatMapResponse
{
public long EventId { get; set; }
public string EventName { get; set; } = "";
public List<Section> Sections { get; set; } = new();
public SeatMapSummary Summary { get; set; } = new();
}
public class Section
{
public string Name { get; set; } = "";
public decimal MinPrice { get; set; }
public decimal MaxPrice { get; set; }
public List<SeatRow> Rows { get; set; } = new();
}
public class SeatRow
{
public string RowName { get; set; } = "";
public List<SeatInfo> Seats { get; set; } = new();
}
public class SeatInfo
{
public string SeatNumber { get; set; } = "";
public string Status { get; set; } = "available"; // available, held, sold
public decimal Price { get; set; }
public string? HeldByUser { get; set; }
}
public class SeatMapSummary
{
public int TotalSeats { get; set; }
public int Available { get; set; }
public int Held { get; set; }
public int Sold { get; set; }
public decimal MinPrice { get; set; }
public decimal MaxPrice { get; set; }
}
Seat Map Caching
The seat map is the most frequently accessed data in the booking system — every user who views an event page requests the seat map. The challenge is that the seat map has two components with very different change frequencies: the static layout (section positions, row numbers, seat coordinates) changes rarely (only when the venue configuration changes), while the dynamic status (available/held/sold) changes constantly (every time a seat is booked or released). The optimal caching strategy separates these two components: cache the static layout on the CDN with a 1-hour TTL (it rarely changes), and stream the dynamic status via WebSocket (it changes in real-time). This separation reduces the seat map response size from 50KB (full map with status) to 5KB (static layout only), with the dynamic status delivered incrementally via WebSocket updates. The result is faster initial page loads and lower bandwidth usage.
| Layer | Data | TTL | Refresh |
|---|---|---|---|
| CDN | Static seat layout (positions, sections) | 1 hour | On venue update |
| Redis | Seat status (available/held/sold) | Real-time | On every change |
| WebSocket | Live status updates | Real-time | Push on change |
8. Concurrent Booking & Race Conditions
The most critical challenge in ticket booking is preventing race conditions during concurrent bookings. Without proper synchronization, two users can simultaneously check seat availability, both see it as available, and both attempt to book it — resulting in a double-booking. This is a classic distributed systems problem that requires careful coordination. The race condition window is typically 10-100 milliseconds (the time between checking availability and updating the status), but during flash sales, millions of requests can fall within this window.
The fundamental insight is that the check-and-update operation must be atomic — no other process can observe or modify the seat between the check and the update. There are several ways to achieve this atomicity: Redis Lua scripts execute atomically on the Redis server, database pessimistic locks (SELECT FOR UPDATE) serialize access to the row, and optimistic locks (version columns) detect conflicts after the fact. Each approach has different trade-offs in terms of throughput, latency, and complexity. For most booking systems, a combination of Redis atomic operations for the hot path and database transactions for the booking confirmation provides the best balance.
Race Condition Prevention Strategies
| Strategy | Mechanism | Pros | Cons |
|---|---|---|---|
| Redis atomic operations | Lua script + WATCH | Fast, simple | Redis-only, no cross-store |
| Database pessimistic lock | SELECT FOR UPDATE | Strong consistency | Blocking, reduces throughput |
| Database optimistic lock | version column + CAS | Non-blocking | High retry rate under contention |
| Distributed lock (Redlock) | Redlock algorithm | Cross-service coordination | Complex, latency overhead |
| Single-writer queue | One process per event | Simple, no locks | Single point of failure |
Optimistic Locking Implementation
C#
public class OptimisticBookingService
{
private readonly AppDbContext _db;
public async Task<BookingResult> BookWithOptimisticLock(
long userId, long eventId, List<long> seatIds)
{
const int MaxRetries = 5;
for (int attempt = 0; attempt < MaxRetries; attempt++)
{
using var tx = await _db.Database.BeginTransactionAsync();
try
{
// Read seats with current version
var seats = await _db.Seats
.Where(s => seatIds.Contains(s.Id))
.ToListAsync();
// Check all seats are available
if (seats.Any(s => s.Status != "available"))
{
await tx.RollbackAsync();
return BookingResult.Failure("One or more seats no longer available");
}
// Record current versions
var versions = seats.ToDictionary(s => s.Id, s => s.Version);
// Attempt to book
foreach (var seat in seats)
{
seat.Status = "held";
seat.HeldBy = userId;
seat.HeldUntil = DateTime.UtcNow.AddMinutes(10);
}
// Optimistic check: UPDATE with version condition
int affected = await _db.Database.ExecuteSqlRawAsync(@"
UPDATE seats
SET status = 'held', held_by = {0}, held_until = {1}
WHERE id IN ({2})
AND status = 'available'
AND version = CASE
WHEN id = {3} THEN {4}
WHEN id = {5} THEN {6}
-- ... for each seat
END",
userId,
DateTime.UtcNow.AddMinutes(10),
seatIds,
// ... version parameters
);
if (affected == seatIds.Count)
{
await tx.CommitAsync();
return BookingResult.Success();
}
// Version mismatch — someone else booked first
await tx.RollbackAsync();
await Task.Delay(50 * (attempt + 1)); // Backoff
}
catch (Exception)
{
await tx.RollbackAsync();
throw;
}
}
return BookingResult.Failure("Booking failed after retries");
}
}
Pessimistic Locking with SELECT FOR UPDATE
SQL
-- Pessimistic lock: lock seats row-by-row
BEGIN TRANSACTION;
SELECT * FROM seats
WHERE id IN (101, 102, 103)
FOR UPDATE; -- Blocks other transactions from modifying these rows
-- Now safely update
UPDATE seats
SET status = 'held', held_by = 456, held_until = NOW() + INTERVAL '10 minutes'
WHERE id IN (101, 102, 103)
AND status = 'available';
-- Check if all seats were updated
SELECT COUNT(*) FROM seats
WHERE id IN (101, 102, 103)
AND status = 'held' AND held_by = 456;
COMMIT;
Concurrency Strategy Comparison
| Strategy | Throughput | Consistency | Complexity | Best For |
|---|---|---|---|---|
| Redis Lua script | 100K+ ops/sec | Atomic (single key) | Low | High-throughput seat holds |
| Optimistic lock | 10K ops/sec | Eventual (with retries) | Medium | Moderate contention |
| Pessimistic lock | 1K ops/sec | Strong (serial) | Low | Low contention, high value |
| Redlock | 50K ops/sec | Distributed lock | High | Cross-service coordination |
| Single-writer queue | 50K ops/sec | Strong (serial) | Medium | Flash sales per event |
9. Distributed Locking Strategies
Redlock Implementation
Redlock is a distributed locking algorithm designed for Redis. It acquires a lock on multiple independent Redis instances and considers the lock acquired if a majority (quorum) of instances confirm the lock. This provides protection against Redis instance failures — if one instance crashes, the lock is still held on the remaining instances. However, Redlock has known issues with clock drift and network partitions, which can lead to safety violations in extreme cases. For most booking systems, Redis Lua scripts (which execute atomically on a single instance) are sufficient because they provide stronger guarantees for single-key operations. Use Redlock only when you need to coordinate across multiple services that don't share a Redis instance.
C#
public class DistributedBookingLock
{
private readonly IConnectionMultiplexer[] _redisInstances;
private readonly TimeSpan _lockTtl = TimeSpan.FromSeconds(10);
public async Task<IDisposable?> AcquireEventLock(long eventId)
{
string lockKey = $"lock:booking:{eventId}";
string lockValue = Guid.NewGuid().ToString();
int quorumCount = _redisInstances.Length / 2 + 1;
var sw = Stopwatch.StartNew();
int retryCount = 0;
while (sw.Elapsed < _lockTtl)
{
int acquired = 0;
foreach (var instance in _redisInstances)
{
bool success = await instance.GetDatabase().StringSetAsync(
lockKey, lockValue, _lockTtl, When.NotExists);
if (success) acquired++;
}
if (acquired >= quorumCount)
{
return new DistributedLockReleaser(
_redisInstances, lockKey, lockValue);
}
// Wait before retry (exponential backoff)
retryCount++;
await Task.Delay(TimeSpan.FromMilliseconds(100 * retryCount));
}
return null; // Could not acquire lock
}
}
public class DistributedLockReleaser : IDisposable
{
private readonly IConnectionMultiplexer[] _instances;
private readonly string _key;
private readonly string _value;
public void Dispose()
{
// Release lock on all instances
string script = @"
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
end
return 0";
foreach (var instance in _instances)
{
_ = instance.GetDatabase().ScriptEvaluateAsync(
script, new RedisKey[] { _key }, new RedisValue[] { _value });
}
}
}
Lock Granularity Strategies
The choice of lock granularity directly impacts throughput and contention. A global lock (one lock for all bookings) serializes all operations and provides maximum safety but minimum throughput — only one booking can be processed at a time across the entire system. Per-event locks allow concurrent bookings for different events but serialize bookings for the same event. Per-section locks allow concurrent bookings within the same event but for different sections. Per-seat locks (implemented via Redis atomic operations) provide maximum throughput because each seat can be booked independently. The recommended approach is per-seat atomic operations for the hot path (seat hold/release) and per-event locks for the cold path (booking confirmation), providing both maximum throughput and strong consistency where it matters.
| Scope | Lock Key | Throughput | Contention |
|---|---|---|---|
| Global | lock:booking:global | 1 booking/sec | Maximum |
| Per event | lock:booking:{eventId} | 1K-10K/sec | Per event |
| Per section | lock:booking:{eventId}:{section} | 10K-50K/sec | Per section |
| Per seat | lock:booking:{seatId} | 100K+/sec | None |
| No lock (Redis atomic) | N/A (Lua script) | 100K+/sec | None |
10. Payment Processing
Payment processing in ticket booking must handle idempotency (no double charges), partial payments (group bookings), and eventual confirmation. The payment flow is a distributed transaction that spans the booking service, payment gateway, and notification service. Payment processing is typically the slowest part of the booking flow (200-500ms for the gateway call), so it must be designed to not block seat inventory operations. The recommended pattern is: hold seats first (fast, Redis), then process payment (slower, external gateway), then confirm booking (database write). If payment fails, release the seats. This ensures seats are held for the minimum time necessary while still providing a good user experience.
Payment State Machine
Idempotent Payment Processing
C#
public class PaymentService
{
private readonly IPaymentGateway _gateway;
private readonly AppDbContext _db;
public async Task<PaymentResult> ProcessPayment(
long bookingId, string paymentMethodId, decimal amount)
{
// Idempotency check
string idempotencyKey = $"payment:{bookingId}";
var existing = await _db.Payments
.FirstOrDefaultAsync(p => p.BookingId == bookingId
&& p.Status == "completed");
if (existing != null)
return PaymentResult.AlreadyProcessed(existing.TransactionId);
// Create payment record
var payment = new Payment
{
BookingId = bookingId,
Method = paymentMethodId,
Amount = amount,
Status = "processing",
CreatedAt = DateTime.UtcNow
};
_db.Payments.Add(payment);
await _db.SaveChangesAsync();
try
{
// Call payment gateway
var gatewayResult = await _gateway.ChargeAsync(
amount: amount,
currency: "USD",
paymentMethodId: paymentMethodId,
idempotencyKey: idempotencyKey,
metadata: new Dictionary<string, string>
{
["booking_id"] = bookingId.ToString()
});
payment.Status = "completed";
payment.TransactionId = gatewayResult.TransactionId;
payment.GatewayResponse = JsonSerializer.Serialize(gatewayResult);
payment.PaidAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
return PaymentResult.Success(gatewayResult.TransactionId);
}
catch (PaymentDeclinedException ex)
{
payment.Status = "failed";
payment.GatewayResponse = ex.Message;
await _db.SaveChangesAsync();
return PaymentResult.Declined(ex.Message);
}
}
}
Payment Gateway Integration
The payment gateway integration is the most critical external dependency in the booking system. Payment processing typically takes 200-500ms, which is the slowest step in the booking flow. The gateway must provide idempotency keys to prevent double charges on retries, support webhooks for asynchronous payment status updates, and handle currency conversion for international bookings. Stripe is the most popular choice for global platforms because it provides idempotency keys natively, supports 135+ currencies, and has a 99.99% uptime SLA. For platforms that need to process payments in regions where Stripe is not available, Adyen provides similar capabilities with better local payment method support. The key architectural decision is whether to process payments synchronously (user waits for confirmation) or asynchronously (user gets a pending status, then a confirmation later). Synchronous is preferred for better UX, but asynchronous is more resilient to gateway timeouts.
| Gateway | Features | Latency | Best For |
|---|---|---|---|
| Stripe | Idempotency keys, webhooks, fraud detection | 200-500ms | Global platforms |
| PayPal | Vault, buyer protection, split payments | 300-800ms | Marketplaces |
| Square | In-person + online, inventory sync | 200-400ms | Physical venues |
| Adyen | Multi-PSP, local payment methods | 200-600ms | International |
Saga Pattern for Booking + Payment
C#
public class BookingSaga
{
public async Task<BookingResult> ExecuteBooking(BookingRequest request)
{
var saga = new SagaBuilder()
.Step("Hold Seats",
execute: () => HoldSeats(request),
compensate: () => ReleaseSeats(request))
.Step("Process Payment",
execute: () => ProcessPayment(request),
compensate: () => RefundPayment(request))
.Step("Confirm Booking",
execute: () => ConfirmBooking(request),
compensate: () => CancelBooking(request))
.Step("Send Confirmation",
execute: () => SendConfirmation(request))
.Build();
return await saga.Execute();
}
}
11. Booking Queue & Waitlist
When events sell out quickly, a waitlist system manages demand fairly. Users join a queue and are notified when seats become available due to cancellations. The queue must be fair (FIFO), durable (survive crashes), and efficient (process thousands of waitlist entries per second). The waitlist is not just a convenience feature — it is a revenue recovery mechanism. Every cancelled ticket that goes to a waitlisted user is revenue that would otherwise be lost. For popular events, the waitlist can be 10-100x larger than the venue capacity, representing significant potential revenue.
Virtual Waiting Room for Flash Sales
C#
public class VirtualWaitingRoom
{
private readonly IDatabase _redis;
public async Task<QueuePosition> JoinQueue(string eventId, string userId)
{
string queueKey = $"queue:{eventId}";
// Add user to queue (sorted by join time)
double score = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
await _redis.SortedSetAddAsync(queueKey, userId, score);
// Get position
long position = await _redis.SortedSetRankAsync(queueKey, userId) + 1;
// Store user's connection info for later notification
await _redis.HashSetAsync($"queue:{eventId}:user:{userId}",
new HashEntry[] {
new("joined_at", score),
new("position", position)
});
return new QueuePosition
{
Position = position,
EstimatedWait = CalculateEstimatedWait(position),
QueueLength = await _redis.SortedSetLengthAsync(queueKey)
};
}
public async Task<string?> GetNextInQueue(string eventId)
{
string queueKey = $"queue:{eventId}";
var next = await _redis.SortedSetRangeByRankAsync(queueKey, 0, 0);
return next.FirstOrDefault();
}
public async Task RemoveFromQueue(string eventId, string userId)
{
await _redis.SortedSetRemoveAsync($"queue:{eventId}", userId);
}
}
Waitlist Notification Flow
Queue Architecture Comparison
| Technology | Throughput | Durability | Fairness | Best For |
|---|---|---|---|---|
| Redis Sorted Set | 100K+/sec | AOF/RDB | FIFO (time-based score) | Real-time queues |
| Kafka | 1M+/sec | Disk replication | Partition-ordered | Event-driven processing |
| PostgreSQL queue | 10K/sec | ACID | FIFO (ID order) | Durability-critical |
| RabbitMQ | 50K/sec | Persistent queues | FIFO with priority | Complex routing |
12. Flash Sale Architecture
Flash sales are the most stressful scenario for ticket booking systems. A Taylor Swift concert on-sale can attract 14 million concurrent users competing for 50,000 seats. The system must serve a fair queue, prevent bots, and process 100,000 bookings per minute without crashing. The key insight is that flash sales are predictable — you know exactly when they will happen and roughly how much traffic to expect. This allows you to pre-provision infrastructure, pre-warm caches, and activate a virtual waiting room before the sale opens. The difference between a successful flash sale and a catastrophic one is preparation.
The most important optimization is the virtual waiting room. Without it, 14 million users hitting the booking API simultaneously will overwhelm even the largest infrastructure. The virtual waiting room acts as a traffic shaper — it accepts all incoming requests, assigns queue positions, and gradually releases users to the booking system at a rate it can handle (typically 1,000 concurrent users). This reduces 14 million concurrent requests to 1,000 concurrent booking operations, which is well within the capacity of a properly configured system. Users see a queue page with their position and estimated wait time, which is much better than a crashed website.
Flash Sale Optimization Checklist
| Optimization | Impact | Implementation |
|---|---|---|
| Pre-warm Redis cache | 50% faster seat lookups | Load all seat data before sale opens |
| Static asset CDN | 80% less origin traffic | Cache event pages, seat maps on CDN |
| Virtual waiting room | Prevents thundering herd | Queue users, process in order |
| Bot detection | Prevents scalpers | Device fingerprint, CAPTCHA, rate limit |
| Auto-scaling rules | Scale based on queue depth and CPU | Handle 100x traffic spikes |
| Seat-level locking | Maximize throughput | Redis Lua per-seat atomic operations |
| Payment retry queue | Handle payment timeouts | Async payment processing via Kafka |
| Graceful degradation | Keep core flow working | Disable non-essential features during peak |
Flash Sale C# Implementation
C#
public class FlashSaleHandler
{
private readonly VirtualWaitingRoom _queue;
private readonly SeatInventoryService _inventory;
private readonly IPaymentGateway _payment;
private readonly ICacheService _cache;
public async Task HandleFlashSaleRequest(
string eventId, string userId, FlashSaleRequest request)
{
// Step 1: Check if user is in the virtual room
var position = await _queue.GetPosition(eventId, userId);
if (position == null)
throw new NotInQueueException("Please join the waiting room");
if (position > 1000) // Only top 1000 can book simultaneously
{
// Return queue position, user must wait
return; // Client polls for position updates
}
// Step 2: Hold seats atomically
bool held = await _inventory.HoldMultipleSeats(
eventId, request.Seats, userId, TimeSpan.FromMinutes(10));
if (!held)
{
// Some seats were taken — suggest alternatives
var alternatives = await _inventory.FindAlternatives(
eventId, request.PreferredSection, request.MaxPrice);
throw new SeatsUnavailableException(alternatives);
}
// Step 3: Process payment with timeout
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
var paymentResult = await _payment.ChargeWithTimeout(
request.PaymentMethod, request.TotalAmount, cts.Token);
if (paymentResult.Succeeded)
{
// Step 4: Confirm booking
await ConfirmBooking(eventId, userId, request);
await _queue.RemoveFromQueue(eventId, userId);
}
else
{
// Release held seats
await _inventory.ReleaseSeats(eventId, request.Seats, userId);
}
}
}
13. Caching Strategy
Multi-Level Cache Architecture
Caching in ticket booking systems is uniquely challenging because the data changes in real-time (seat availability) while also being read-heavy (browse, search, seat map). The solution is a multi-level cache with different TTL strategies for different data types. Static data (event descriptions, venue layouts, pricing tiers) can be cached aggressively with 30-minute TTLs. Semi-static data (event search results, category pages) can be cached for 5 minutes. Dynamic data (seat availability) must be updated in real-time via write-through caching — when a seat status changes, the cache is updated immediately, not invalidated for later re-fetching. This write-through pattern ensures the cache is always consistent with the source of truth, which is critical for preventing double-bookings.
| Layer | Data | TTL | Invalidation |
|---|---|---|---|
| CDN | Event pages, static assets | 1 hour | Purge on event update |
| Redis (search) | Event search results | 5 minutes | TTL-based |
| Redis (seats) | Seat availability map | Real-time | Write-through on change |
| Redis (user) | User's held seats, cart | 10 minutes | On checkout/cancel |
| Application | Venue layouts, pricing tiers | 30 minutes | On config change |
C#
public class BookingCacheService
{
private readonly IDatabase _redis;
public async Task CacheSeatMap(long eventId, SeatMapResponse map)
{
string key = $"seatmap:{eventId}";
await _redis.StringSetAsync(key,
JsonSerializer.Serialize(map),
TimeSpan.FromMinutes(5));
}
public async Task InvalidateSeatMap(long eventId)
{
await _redis.KeyDeleteAsync($"seatmap:{eventId}");
// Also invalidate CDN cache
await _cdn.PurgeAsync($"/api/v1/events/{eventId}/seats");
}
public async Task CacheUserCart(string userId, CartItem item)
{
string key = $"cart:{userId}";
await _redis.StringSetAsync(key,
JsonSerializer.Serialize(item),
TimeSpan.FromMinutes(10));
}
}
14. Notification System
Booking Confirmation Flow
The notification system for ticket booking must handle multiple channels (email, SMS, push, in-app) with different delivery guarantees and latency requirements. Booking confirmations must be delivered within 30 seconds of payment success. Event reminders must be sent exactly 24 hours before the event. Waitlist notifications must be delivered within 1 minute of availability. The architecture uses Kafka as a buffer between the booking service and the notification services, ensuring that notification delivery failures don't block the booking flow. Each notification type has its own consumer group with independent retry logic and dead letter queues.
Notification Templates
| Event | Channel | Template | Timing |
|---|---|---|---|
| Booking confirmed | Email + SMS + Push | Booking details + QR ticket | Immediately |
| Payment receipt | Invoice + receipt | Immediately | |
| Event reminder | Email + Push | Event details + directions | 24h before event |
| Waitlist update | Email + Push | Tickets available, 15-min window | On availability |
| Booking cancelled | Email + SMS | Cancellation + refund status | Immediately |
| Hold expiring | Push | Complete checkout in 5 minutes | 5 min before expiry |
15. Cancellation & Refund
Refund Policy Engine
The refund policy engine determines how much of a booking is refundable based on when the cancellation occurs relative to the event date. Most ticketing platforms use a tiered policy: full refund if cancelled more than 72 hours before the event, 75% refund for 24-72 hours, 50% for 6-24 hours, and no refund for less than 6 hours. The policy is configurable per event — organizers can set their own rules. The engine must also handle edge cases like event cancellation (full refund), venue changes (refund option), and force majeure (partial or full refund depending on insurance). The refund calculation must be idempotent — calling it twice with the same inputs must produce the same result — because payment gateways may retry refund webhooks.
C#
public class RefundPolicyEngine
{
public RefundResult CalculateRefund(Booking booking, DateTime cancelTime)
{
var eventDate = booking.Event.EventDate;
var hoursUntilEvent = (eventDate - cancelTime).TotalHours;
var policy = booking.Event.RefundPolicy ?? GetDefaultPolicy();
return hoursUntilEvent switch
{
> 72 => new RefundResult
{
RefundPercentage = 100,
Reason = "Full refund: cancelled more than 72 hours before event"
},
> 24 => new RefundResult
{
RefundPercentage = 75,
Reason = "Partial refund: cancelled 24-72 hours before event"
},
> 6 => new RefundResult
{
RefundPercentage = 50,
Reason = "Partial refund: cancelled 6-24 hours before event"
},
> 0 => new RefundResult
{
RefundPercentage = 0,
Reason = "No refund: cancelled less than 6 hours before event"
},
_ => new RefundResult
{
RefundPercentage = 0,
Reason = "No refund: event has already started"
}
};
}
}
Cancellation Flow
The cancellation flow must handle the reverse of the booking flow: calculate the refund amount based on the refund policy, process the refund through the payment gateway, release the seats back to inventory, update the booking status, and notify the waitlist. The cancellation must be idempotent — cancelling the same booking twice must produce the same result. The seat release must trigger a check of the waitlist to notify the next user in queue. For popular events, this waitlist notification can result in a new booking within minutes, recovering the revenue from the cancellation. The entire cancellation flow should complete within 5 seconds, with the refund appearing in the user's account within 3-5 business days (depending on the payment method and bank).
C#
public class CancellationService
{
private readonly AppDbContext _db;
private readonly IPaymentGateway _gateway;
private readonly SeatInventoryService _inventory;
public async Task<CancellationResult> CancelBooking(
long bookingId, long userId, string reason)
{
var booking = await _db.Bookings
.Include(b => b.Event)
.FirstOrDefaultAsync(b => b.Id == bookingId && b.UserId == userId);
if (booking == null)
return CancellationResult.NotFound();
if (booking.Status != "confirmed")
return CancellationResult.InvalidStatus(booking.Status);
// Calculate refund
var refund = _refundPolicy.CalculateRefund(booking, DateTime.UtcNow);
// Process refund
if (refund.RefundPercentage > 0)
{
decimal refundAmount = booking.TotalAmount * refund.RefundPercentage / 100;
await _gateway.RefundAsync(
booking.Payment.TransactionId, refundAmount);
}
// Release seats back to inventory
await _inventory.ReleaseSeats(booking.EventId, booking.SeatIds, userId);
// Update booking status
booking.Status = "cancelled";
booking.CancelledAt = DateTime.UtcNow;
booking.RefundAmount = booking.TotalAmount * refund.RefundPercentage / 100;
await _db.SaveChangesAsync();
// Notify waitlist if seats become available
if (refund.RefundPercentage > 0)
{
await _waitlistService.NotifyAvailability(booking.EventId, booking.SeatIds);
}
return CancellationResult.Success(refund);
}
}
16. Analytics & Reporting
Key Business Metrics
Analytics for ticket booking systems must track both business metrics (revenue, conversion, sell-through) and operational metrics (latency, error rates, queue depth). The business metrics drive pricing decisions, marketing spend, and event planning. The operational metrics drive infrastructure scaling, capacity planning, and incident response. The most important metric is the booking conversion rate — the percentage of event page views that result in a completed booking. Industry benchmarks show 3-5% conversion for normal events and 15-30% for flash sales (where demand exceeds supply). A 1% improvement in conversion rate for a major event can mean millions of dollars in additional revenue.
| Metric | Description | Target |
|---|---|---|
| Conversion rate | Bookings / Event page views | 3-5% |
| Cart abandonment rate | Started checkout but didn't complete | < 30% |
| Average booking time | Time from seat selection to confirmation | < 3 minutes |
| Payment success rate | Successful payments / Total attempts | > 95% |
| Cancellation rate | Cancelled bookings / Total bookings | < 5% |
| Flash sale sell-through | Tickets sold in first hour / Total tickets | > 80% |
| Revenue per event | Total revenue / Events hosted | Trending |
Analytics Data Pipeline
The analytics pipeline must handle high-throughput event ingestion while providing near-real-time dashboards for business operators. Every booking, payment, cancellation, and seat hold generates an event that flows through Kafka to both the analytics database (ClickHouse for OLAP queries) and the real-time dashboard (Redis for counters). The pipeline must handle 5 million booking events per day plus 50 million browse/search events, processing them within 30 seconds for real-time dashboards and within 1 hour for historical reports. The key insight is separating real-time analytics (Redis counters for live dashboards) from historical analytics (ClickHouse for deep analysis), because they have different latency and consistency requirements.
SQL
-- Daily revenue summary
SELECT
DATE(booked_at) AS booking_date,
COUNT(*) AS total_bookings,
SUM(total_amount) AS revenue,
AVG(total_amount) AS avg_booking_value,
COUNT(DISTINCT user_id) AS unique_buyers
FROM bookings
WHERE status = 'confirmed'
AND booked_at >= CURRENT_DATE - INTERVAL '30 days'
GROUP BY DATE(booked_at)
ORDER BY booking_date DESC;
-- Event performance report
SELECT
e.name AS event_name,
e.event_date,
COUNT(b.id) AS tickets_sold,
SUM(b.total_amount) AS revenue,
COUNT(b.id) * 100.0 / e.total_seats AS sell_through_rate,
AVG(EXTRACT(EPOCH FROM (b.booked_at - e.sale_start))) AS avg_time_to_sell
FROM events e
LEFT JOIN bookings b ON e.id = b.event_id AND b.status = 'confirmed'
WHERE e.event_date >= CURRENT_DATE
GROUP BY e.id, e.name, e.event_date, e.total_seats
ORDER BY revenue DESC;
17. Scaling the Booking System
Scaling Strategy
Ticket booking systems must handle three distinct load profiles: normal operations (steady-state browsing and booking), peak load (popular event on-sales), and flash sales (extreme demand spikes). The scaling strategy must address all three. Normal operations can be handled by a small cluster (3-10 servers). Peak load requires auto-scaling to 10x normal capacity. Flash sales require pre-provisioned infrastructure at 100x normal capacity plus a virtual waiting room to shape traffic. The key insight is that flash sales are predictable — you know the exact date and time — so you can pre-provision infrastructure and activate the virtual waiting room before the sale opens. This is much more reliable than trying to auto-scale reactively during the sale.
Database Sharding Strategy
Database sharding is essential for scaling beyond a single PostgreSQL instance. The choice of shard key determines the distribution of data and query patterns. Sharding by event_id distributes event data evenly but creates hot shards for popular events (a Taylor Swift concert might have 100x more bookings than a local theater show). Sharding by user_id distributes user data evenly and avoids hot shards, but makes event-centric queries (all bookings for an event) require cross-shard scatter-gather. For most booking systems, sharding by event_id with hot-event handling (dedicated shards for popular events) provides the best balance. The key insight is that 80% of bookings come from 20% of events — these "hot events" should be placed on dedicated shards with extra capacity, while the remaining 80% of events can share standard shards.
| Shard Key | Distribution | Hot Shard Risk | Best For |
|---|---|---|---|
| event_id | Even (popular events distributed) | High for mega events | Event-centric queries |
| user_id | Even (users distributed) | Low | User-centric queries |
| venue_id | Uneven (popular venues) | High | Venue management |
| Geographic region | Uneven (population density) | Medium | Multi-region |
Auto-Scaling Rules
YAML
# Kubernetes HPA for booking service
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: booking-service
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: booking-service
minReplicas: 3
maxReplicas: 300
behavior:
scaleUp:
stabilizationWindowSeconds: 30
policies:
- type: Percent
value: 100
periodSeconds: 30
scaleDown:
stabilizationWindowSeconds: 300
policies:
- type: Percent
value: 10
periodSeconds: 60
metrics:
- type: Pods
pods:
metric:
name: booking_queue_depth
target:
type: AverageValue
averageValue: "100"
18. Monitoring & Observability
Key Metrics
| Metric | Target | Alert Threshold |
|---|---|---|
| Booking success rate | > 99% | < 97% |
| Booking latency (p99) | < 2s | > 5s |
| Payment success rate | > 95% | < 90% |
| Seat hold expiry rate | < 20% | > 40% |
| Double-booking incidents | 0 | Any occurrence |
| Queue depth (flash sale) | < 50K | > 200K |
| Redis memory usage | < 80% | > 90% |
| DB connection pool usage | < 70% | > 85% |
| Seat hold expiry rate | < 20% of holds expire | > 40% expiry rate |
| Waitlist conversion rate | > 50% of notified users book | < 20% conversion |
| Notification delivery latency | < 30 seconds | > 60 seconds |
SLO Definition
The SLOs for a ticket booking system are stricter than most web applications because failures directly impact revenue and user trust. The zero-tolerance SLO for double-bookings is the most critical — a single double-booking incident can damage the platform's reputation and lead to legal liability. The availability SLO of 99.99% (4.32 minutes of downtime per month) is essential because downtime during a flash sale can cost millions in lost revenue. The booking success rate SLO of 99.9% ensures that 99.9% of valid booking attempts complete successfully. The payment processing SLO of 99.95% accounts for the fact that payment gateways occasionally have outages, and the system must handle these gracefully (queue the payment and retry when the gateway recovers).
| SLO | Target | Error Budget (30 days) |
|---|---|---|
| Availability | 99.99% | 4.32 minutes |
| Booking success rate | 99.9% | 43.2 minutes of failures |
| Zero double-bookings | 100% | Zero tolerance |
| Payment processing | 99.95% | 21.6 minutes |
19. Cost Estimation
Monthly Infrastructure Cost (5M bookings/day)
| Component | Spec | Monthly Cost |
|---|---|---|
| Application servers | 10 × m5.xlarge (normal), 100 × m5.xlarge (peak) | ~$14,000 |
| PostgreSQL cluster | 4 shards × primary + 2 replicas (r5.xlarge) | ~$17,200 |
| Redis cluster | 12 nodes × r5.xlarge | ~$11,400 |
| Elasticsearch | 6 nodes × m5.xlarge | ~$4,800 |
| Kafka cluster | 6 nodes × m5.xlarge | ~$3,400 |
| CDN (CloudFront) | 10TB/month | ~$850 |
| Payment gateway fees | 2.9% + $0.30 per transaction | ~$725,000 |
| Email/SMS (notifications) | 5M emails + 1M SMS | ~$2,000 |
| Monitoring (Datadog) | 20 hosts | ~$2,000 |
| Total (infra only) | ~$55,650/month | |
| Total (with payment fees) | ~$780,650/month |
Revenue Model
The revenue model for ticket booking platforms is built on three pillars: booking fees (a flat fee per ticket, typically $5-15), service fees (a percentage of the ticket price, typically 5-10%), and dynamic pricing (price increases during high-demand periods). The booking fee is the most predictable revenue stream because it is per-ticket regardless of price. The service fee scales with ticket price, making it more lucrative for premium events. Dynamic pricing is the most controversial but also the most profitable — it can increase revenue by 10-30% for high-demand events. The key challenge is balancing dynamic pricing with user trust — prices that change too frequently or too dramatically can damage the platform's reputation. Most platforms show a "price lock" guarantee during checkout to prevent sticker shock.
When analyzing the unit economics, a typical concert ticket priced at $100 generates approximately $8 in booking fees, $7 in service fees, and potentially $15-30 in dynamic pricing uplift. After accounting for infrastructure costs (~$0.05 per booking) and payment processing fees (~$3.50 per transaction), the platform retains approximately $12-25 per ticket — a 12-25% margin. For a major event selling 50,000 tickets, this translates to $600K-1.25M in platform revenue from a single event. At scale (500 million tickets per year), even small improvements in conversion rate or fee optimization translate to significant revenue impact. A 0.5% improvement in conversion rate across 500M tickets means 2.5M additional bookings, which at $15 average fee per booking represents $37.5M in additional annual revenue.
| Revenue Stream | Rate | Monthly Revenue |
|---|---|---|
| Booking fees (per ticket) | $5-15 per ticket × 150M tickets/month | $750M - $2.25B |
| Service fees (% of order) | 5-10% of $25 avg ticket | $187M - $375M |
| Dynamic pricing uplift | 10-30% price increase on high-demand events | Additional 10-30% |
| Advertising revenue | Event promotion, sponsored listings | $10-50M |
| Data licensing | Anonymized booking data for market research | $5-20M |
| White-label platform | Licensing the booking engine to venues | $20-100M |
| Insurance products | Ticket protection insurance (10-15% of ticket price) | $50-200M |
20. Case Studies — Production Systems
Ticketmaster
Ticketmaster is the world's largest ticket marketplace, processing over 500 million tickets per year across 30+ countries. Their architecture is built to handle extreme load spikes during major event on-sales. When Taylor Swift's Eras Tour went on sale, Ticketmaster processed 14 million requests in a single minute, with a peak of 3.5 million users simultaneously in the virtual waiting room. Their system handled this by deploying a sophisticated virtual waiting room that queued users by geographic region, processing the first tranche of fans (those with verified fan codes) before opening to the general public. The key to their success is pre-provisioning infrastructure — they know exactly when sales will happen and scale up weeks in advance.
| Component | Details |
|---|---|
| Scale | 30M+ tickets/month, 14M requests/minute at peak |
| Inventory | Custom distributed inventory system |
| Queue | Virtual waiting room with queue position |
| Bot protection | Advanced bot detection + CAPTCHA |
| Payment | Stripe + custom fraud detection |
| Ticket delivery | Mobile-first with Apple Wallet/Google Pay |
Booking.com
Booking.com is the world's largest accommodation marketplace, with over 28 million listings across 220+ countries. They handle 1.5 million room nights every day, with peak traffic during holiday seasons and special events. Their inventory system is fundamentally different from event ticketing because hotel rooms are reusable inventory — the same room can be booked for different dates. This requires a date-based inventory model where availability is checked per night, not per event. Their overbooking model uses machine learning to predict no-show rates and intentionally overbook by 2-5% to maximize occupancy. The challenge is that overbooking must be carefully calibrated — too much and you have walk-ins (guests with confirmed reservations but no room), too little and you lose revenue from empty rooms. Their cancellation policies are also more complex — some listings offer free cancellation up to 24 hours before check-in, while others are non-refundable. The system must track per-listing cancellation policies and calculate refund eligibility accordingly.
| Component | Details |
|---|---|
| Scale | 28M+ listings, 1.5M room nights/day |
| Inventory | Real-time room availability from hotel PMS |
| Overbooking | Predictive model to handle no-shows |
| Pricing | Dynamic pricing based on demand, season, events |
| Payment | Pay at hotel or pay now options |
| Cancellation | Flexible cancellation policies per listing |
Key Lessons from Production Systems
The most important lesson from production ticket booking systems is that the booking flow must be designed for failure at every step. Network requests to payment gateways can timeout. Database writes can fail. Redis can lose data. The system must handle each failure gracefully without losing money or double-booking seats. The Saga pattern with compensation is the standard approach: if any step fails, compensate by undoing the previous steps (release seats, refund payment, cancel booking). The second lesson is that performance and correctness are not mutually exclusive — Redis Lua scripts provide both atomic operations (correctness) and sub-millisecond latency (performance). The third lesson is that flash sales are fundamentally different from normal load and require different architecture — virtual waiting rooms, pre-provisioned infrastructure, and bot detection are not optional for flash sales, they are requirements.
| Lesson | Detail | Impact |
|---|---|---|
| Separate search from booking | Search can be eventually consistent, booking cannot | 10x throughput improvement |
| Virtual waiting rooms work | Ticketmaster's queue reduced server load by 90% | Prevented crashes |
| Idempotency is critical | Payment retries without idempotency cause double charges | Zero double charges |
| Graceful degradation wins | Disable recommendations during flash sales | Core flow stays up |
| Pre-warm everything | Cache, connections, auto-scaling before sale opens | 50% faster cold start |
21. Edge Cases
| Edge Case | Impact | Solution |
|---|---|---|
| Payment succeeds but booking fails | User charged but no ticket | Immediate refund + retry booking |
| Hold expires during payment | Seats released mid-checkout | Extend hold when payment starts |
| User books same event twice | Exceeds max tickets per user | Check user's existing bookings before hold |
| Event cancelled by organizer | All bookings need refund | Batch refund + notification pipeline |
| Partial group booking failure | Some seats held, others failed | Atomic all-or-nothing hold |
| Duplicate payment submission | Double charge | Idempotency key per booking |
| Seat map stale after venue change | Wrong seat positions | Version seat maps, invalidate on change |
| Timezone confusion | Wrong event time displayed | Store all times in UTC, display in user's timezone |
| Currency conversion during checkout | Price changes between selection and payment | Lock price at hold time, display disclaimer |
| Accessibility seat requirements | Wheelchair spaces double-booked | Separate inventory for accessible seating |
22. Interview Q&A
Use Redis Lua scripts for atomic seat operations. The script checks the seat status and updates it in a single atomic operation — no other process can read the seat between the check and the update. For database-level protection, use SELECT FOR UPDATE or optimistic locking with version columns. The combination of Redis for real-time inventory and database transactions for booking confirmation provides defense in depth against double-booking.
Implement a virtual waiting room that queues users before they reach the booking system. Users see a queue position and estimated wait time. Process the first 1,000 users at a time, giving each a 10-minute window to complete their booking. This reduces 14M concurrent requests to 1,000 concurrent booking operations — a manageable load. Pre-warm Redis cache, auto-scale application servers, and use CDN for static assets to handle the initial traffic spike.
This is handled by the Saga pattern with compensation. If the payment succeeds but the booking confirmation fails, the system automatically triggers a refund. The payment gateway's idempotency key ensures the refund is processed exactly once. The user receives both a payment receipt and a refund notification. Alternatively, use a two-phase approach: first reserve seats and create a pending booking, then process payment, then confirm. If any step fails, compensate by releasing seats and refunding.
Use WebSockets to push seat status updates in real-time. When a seat is held or sold, all connected clients see the change within milliseconds. The seat map has two layers: static layout (section positions, row numbers) loaded from CDN, and dynamic status (available/held/sold) loaded from Redis via WebSocket. This separation allows the static layout to be heavily cached while the dynamic status updates in real-time.
Implement a pricing engine that adjusts prices based on demand signals: remaining inventory percentage, time until event, historical demand patterns, and competitor pricing. Price changes are published to a Kafka topic and propagated to the pricing cache. Users see the current price when they begin checkout, and the price is locked at hold time. If the price changes between page load and checkout, show a price update notification before payment.
Use a Redis Sorted Set with join timestamp as score for FIFO ordering. When a booking is cancelled, check the waitlist and notify the next user via push notification. Give them a 15-minute window to complete the booking. If they don't respond, skip to the next user. The waitlist must be durable (Redis AOF or PostgreSQL backup) to survive crashes. Store the user's preferred sections and max price to match them with available seats.
Minimize database writes during the sale by using Redis as the primary inventory store. Only write to the database when the booking is confirmed (not when seats are held). Use connection pooling (PgBouncer) to manage database connections. Shard the database by event_id to distribute load. During flash sales, read replicas handle search/browse queries while the primary handles only booking confirmations. This reduces primary DB load by 90%.
Fairness is achieved through the virtual waiting room: users are queued in FIFO order and processed sequentially. Within each user's 10-minute window, they have priority on their selected seats. If two users select the same seat within the same processing window, the Redis atomic operation ensures only one succeeds. The other user sees the seat as unavailable and can choose alternatives. This is fair because the first request (in queue order) always wins.
System Design Framework
| Step | Ticket Booking Approach |
|---|---|
| Requirements | 5M bookings/day, zero double-bookings, 2s p99 latency |
| Back-of-envelope | 5,800 QPS avg, 100K QPS flash sale, 5TB/year storage |
| Data model | Events, seats (with status), bookings, payments |
| API design | Browse events, hold seats, confirm booking, cancel |
| Architecture | Redis (inventory) + PostgreSQL (bookings) + Kafka (events) |
| Deep dive | Atomic seat holds, distributed locking, payment saga |
| Reliability | Idempotency, compensation, graceful degradation |
23. Conclusion
Building a ticket booking system at scale requires solving the fundamental challenge of inventory management under concurrency. The key insight is that seat availability must be managed atomically — Redis Lua scripts provide the fastest path for atomic seat operations, while database transactions ensure durable booking records. The separation of concerns between real-time inventory (Redis), durable bookings (PostgreSQL), and event streaming (Kafka) allows each component to scale independently and handle different consistency requirements.
The booking system must balance three competing concerns: correctness (never double-book), performance (sub-second response), and fairness (queue-based access). Redis provides the performance for real-time inventory, PostgreSQL provides the correctness for financial transactions, and the virtual waiting room provides the fairness for flash sales. This triple guarantee is what separates a production booking system from a prototype. Whether you are building a concert ticket platform, a hotel reservation system, or an airline booking engine, the fundamental architecture remains the same: atomic inventory operations via Redis Lua scripts, idempotent payment processing via gateway idempotency keys, Saga pattern with compensation for the booking flow, and virtual waiting rooms for flash sales. Master these patterns and you can build any reservation or booking system, from a local cinema ticket counter to a global airline booking platform handling millions of transactions per day with zero double-bookings and sub-second response times.
Key Numbers to Remember
| Metric | Value |
|---|---|
| Redis Lua script throughput | 100K+ atomic operations/sec |
| Optimal hold duration | 10 minutes (balance inventory turnover vs UX) |
| Virtual room processing rate | 1,000 users simultaneously |
| Payment gateway timeout | 30 seconds max |
| Waitlist notification window | 15 minutes to respond |
| Double-booking tolerance | Zero (absolute requirement) |
| Flash sale peak QPS | 100,000 bookings/second |
| Seat map WebSocket update latency | < 100ms |
| Seat hold TTL | 10 minutes |
| Waitlist notification window | 15 minutes to complete booking |
| Payment gateway timeout | 30 seconds max before retry |
| Virtual room capacity | 1,000 concurrent users in booking flow |
| Flash sale sell-through target | 80%+ of tickets sold in first hour |
| Event page load time | < 2 seconds (including seat map) |
| Booking completion time | < 3 minutes (seat selection to confirmation) |
| Refund processing time | 3-5 business days to user account |
| QR ticket generation time | < 1 second after payment confirmation |
| Concurrent seat holds per second | > 50,000 via Redis Lua scripts |
| Database transaction success rate | > 99.99% (retry handles transient failures) |
| Average booking fee per ticket | $8-15 (platform revenue model) |
| Target uptime SLA | 99.95% (no more than 4.4 hours downtime per year) |
Production Checklist
- Use Redis Lua scripts for atomic seat hold/release operations — no double-booking possible
- Implement idempotency keys for all payment operations to prevent double charges
- Build virtual waiting rooms for flash sales — process 1,000 users at a time, not 14 million
- Separate search (eventually consistent) from booking (strongly consistent)
- Use Saga pattern with compensation for the booking + payment flow
- Pre-warm caches, auto-scale infrastructure, and use CDN for flash sales
- Implement seat hold TTL (10 minutes) to prevent inventory hoarding and ensure fair turnover
- Send real-time seat updates via WebSocket for the interactive seat map — users must see held/sold seats within 100ms
- Build refund policy engine with configurable time-based rules per event
- Monitor double-booking incidents as a zero-tolerance SLO — any occurrence triggers an immediate incident
Common Interview Mistakes to Avoid
- Using database-level locks for seat holds — too slow for 100K QPS flash sales, use Redis Lua scripts instead
- Not implementing idempotency for payments — causes double charges on retries and is the most common production bug
- Storing seat inventory only in the database — single point of failure and bottleneck during flash sales
- Ignoring the virtual waiting room — 14 million concurrent users will crash any system without traffic shaping
- Not handling the payment-but-no-booking scenario — requires automatic refund compensation via Saga pattern
- Using cache-aside for seat inventory — cache misses can lead to double-booking, use write-through instead
- Forgetting about seat hold expiry — users must see real-time seat status changes via WebSocket within 100ms
- Not separating the hot path (seat operations) from the cold path (search, browse) — they have different consistency requirements
24. Booking Analytics and Revenue Tracking Pipeline
A production ticket booking system requires real-time analytics for revenue tracking, conversion funnel analysis, and demand forecasting. The analytics pipeline must process booking events without impacting the critical booking path, while providing real-time dashboards for operations teams and business stakeholders.
public class BookingAnalyticsPipeline
{
private readonly IKafkaProducer<string, BookingEvent> _producer;
private readonly IDatabase _analyticsDb;
public async Task TrackBookingEventAsync(BookingEvent evt)
{
// Async: publish to Kafka (non-blocking for booking path)
await _producer.ProduceAsync("booking-events",
new Message<string, BookingEvent>
{
Key = evt.BookingId,
Value = evt
});
// Real-time counters for dashboard
var tags = new Dictionary<string, string>
{
["event_id"] = evt.EventId,
["seat_type"] = evt.SeatType,
["payment_method"] = evt.PaymentMethod,
["region"] = evt.UserRegion
};
Metrics.IncrementCounter("bookings.created", tags);
Metrics.RecordRevenue("bookings.revenue", evt.Amount, tags);
Metrics.RecordLatency("bookings.processing_time_ms",
evt.CreatedAt, evt.CompletedAt, tags);
}
public async Task<BookingFunnelReport> GetFunnelReportAsync(
string eventId, DateTimeOffset from, DateTimeOffset to)
{
var pageViews = await _analyticsDb.QueryAsync<long>(
"SELECT COUNT(*) FROM page_views WHERE event_id = @id AND ts BETWEEN @from AND @to",
new { id = eventId, from, to });
var seatSelections = await _analyticsDb.QueryAsync<long>(
"SELECT COUNT(*) FROM seat_selections WHERE event_id = @id AND ts BETWEEN @from AND @to",
new { id = eventId, from, to });
var checkoutStarts = await _analyticsDb.QueryAsync<long>(
"SELECT COUNT(*) FROM checkout_initiated WHERE event_id = @id AND ts BETWEEN @from AND @to",
new { id = eventId, from, to });
var completedBookings = await _analyticsDb.QueryAsync<long>(
"SELECT COUNT(*) FROM bookings WHERE event_id = @id AND status = 'confirmed' AND ts BETWEEN @from AND @to",
new { id = eventId, from, to });
var totalRevenue = await _analyticsDb.QueryAsync<decimal>(
"SELECT COALESCE(SUM(amount), 0) FROM bookings WHERE event_id = @id AND status = 'confirmed' AND ts BETWEEN @from AND @to",
new { id = eventId, from, to });
return new BookingFunnelReport
{
EventId = eventId,
PageViews = pageViews,
SeatSelections = seatSelections,
CheckoutStarts = checkoutStarts,
CompletedBookings = completedBookings,
TotalRevenue = totalRevenue,
ConversionRate = pageViews > 0 ? (double)completedBookings / pageViews : 0,
AbandonmentRate = checkoutStarts > 0
? (double)(checkoutStarts - completedBookings) / checkoutStarts : 0
};
}
}
Key Analytics Metrics
| Metric | Description | Target |
|---|---|---|
| Conversion Rate | Page views to confirmed bookings | 3-8% |
| Checkout Abandonment | Started checkout but didn't complete | < 40% |
| Avg Booking Time | Time from seat selection to confirmation | < 3 minutes |
| Revenue per Event | Total ticket revenue per event | Varies |
| Seat Utilization | Sold seats / total available seats | > 80% |
| Flash Sale Sell-Through | Tickets sold in first hour of sale | > 80% |
Idempotency and Payment Safety
Booking systems must guarantee exactly-once payment processing even when users double-click submit, network retries fire, or services restart mid-transaction. An idempotency key — a unique token generated per booking attempt — ensures that duplicate payment requests are deduplicated at the payment gateway layer. Combined with distributed locking on the seat-inventory level, this prevents double-booking and double-charging.
public class IdempotentBookingService
{
private readonly IPaymentGateway _payment;
private readonly IDistributedLock _lock;
private readonly IIdempotencyStore _idempotencyStore;
public async Task<BookingResult> BookAsync(
BookingRequest request, string idempotencyKey)
{
// Check if this idempotency key was already processed
var existing = await _idempotencyStore
.GetResultAsync(idempotencyKey);
if (existing != null)
return existing; // Return cached result, no double charge
// Acquire distributed lock on the event+seat combination
var lockKey = $"booking:{request.EventId}:{request.SeatId}";
using var lockHandle = await _lock.AcquireAsync(
lockKey, TimeSpan.FromSeconds(30));
try
{
// Reserve inventory
await _inventory.ReserveAsync(
request.EventId, request.SeatId);
// Process payment with idempotency key
var paymentResult = await _payment.ChargeAsync(
new ChargeRequest
{
Amount = request.TotalAmount,
IdempotencyKey = idempotencyKey,
UserId = request.UserId
});
if (!paymentResult.Success)
{
await _inventory.ReleaseAsync(
request.EventId, request.SeatId);
return BookingResult.PaymentFailed(
paymentResult.Error);
}
// Confirm booking
var booking = await _repo.CreateBookingAsync(request);
await _idempotencyStore.StoreResultAsync(
idempotencyKey, booking);
return BookingResult.Success(booking);
}
catch
{
await _inventory.ReleaseAsync(
request.EventId, request.SeatId);
throw;
}
}
}
Payment Safety Metrics
| Metric | Target | Description |
|---|---|---|
| Double-Charge Rate | 0 | Zero duplicate payments due to idempotency |
| Idempotency Key TTL | 24 hours | Duration to cache and deduplicate requests |
| Lock Timeout | 30 seconds | Maximum hold time for distributed seat lock |
| Payment Reconciliation Lag | < 5 minutes | Time to detect and reconcile payment mismatches |