system-design46 min read

How to Design a Ticket Booking System — A Senior+ Guide | Ayodhyya

How to Design a Ticket Booking System

Building Ticketmaster, Booking.com, and Eventbrite at scale: seat selection, inventory management, and concurrent booking

Senior+ Guide 50+ min read 10,000+ words Ayodhyya

Table of Contents

  1. Introduction — The Ticket Booking Landscape
  2. Functional & Non-Functional Requirements
  3. Capacity Estimation & Back-of-Envelope
  4. Data Model & Storage
  5. API Design
  6. High-Level Architecture
  7. Inventory Management & Seat Selection
  8. Concurrent Booking & Race Conditions
  9. Distributed Locking Strategies
  10. Payment Processing
  11. Booking Queue & Waitlist
  12. Flash Sale Architecture
  13. Caching Strategy
  14. Notification System
  15. Cancellation & Refund
  16. Analytics & Reporting
  17. Scaling the Booking System
  18. Monitoring & Observability
  19. Cost Estimation
  20. Case Studies — Production Systems
  21. Edge Cases
  22. Interview Q&A
  23. 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.

Interview Context: The ticket booking design question tests your understanding of distributed transactions, inventory management, race condition prevention, and queue-based architectures. It is a frequent question at Ticketmaster, Booking.com, Expedia, and companies building marketplace or reservation systems.

2. Functional & Non-Functional Requirements

Functional Requirements

#RequirementPriorityDetails
F1Browse eventsMustSearch events by date, venue, artist, category
F2View seat availabilityMustReal-time seat map with status (available, held, sold)
F3Reserve seatsMustHold selected seats for limited time during checkout
F4Process paymentMustSecure payment with multiple methods
F5Confirm bookingMustGenerate tickets with QR codes, send confirmation
F6Cancel/refundMustCancel booking and process refund per policy
F7Seat selection UIShouldInteractive venue map with section/row/seat
F8WaitlistShouldJoin waitlist for sold-out events
F9Dynamic pricingNicePrice changes based on demand
F10Group bookingNiceBook multiple adjacent seats together

Non-Functional Requirements

RequirementTargetRationale
Booking latency< 2 seconds (p99)Users expect fast checkout
Availability99.99%Downtime during flash sales is catastrophic
Double-booking preventionZero toleranceSelling the same seat twice destroys trust
Seat hold TTL10 minutesBalance between user convenience and inventory turnover
Flash sale throughput100K bookings/minuteMajor event releases
Data consistencyStrong (ACID)Financial transactions require strict consistency

3. Capacity Estimation & Back-of-Envelope

Daily Volume Estimates

MetricCalculationResult
Events per dayGiven100,000
Bookings per dayGiven5 million
Seats per event (avg)Given5,000
Total seats available daily100K × 5K500 million
Booking QPS (avg)5M / 86,400~58 QPS
Booking QPS (peak, 100x)58 × 100~5,800 QPS
Flash sale QPSGiven (Taylor Swift scale)~100,000 QPS
Browse/search QPS50M pageviews / 86,400~579 QPS
Seat map requests QPS10M / 86,400~116 QPS

Storage Estimates

DataSize per RecordCountTotal
Event metadata~2 KB1M events/year~2 GB/year
Seat inventory~100 bytes5B seats/year~500 GB/year
Bookings~500 bytes2B bookings/year~1 TB/year
Payment records~300 bytes2B records/year~600 GB/year
User sessions~200 bytes100M sessions/day~20 GB/day
Booking events (Kafka)~200 bytes5M/day~1 GB/day

Bandwidth Estimates

OperationRequests/dayAvg SizeDaily Bandwidth
Browse events50M10 KB~500 GB
Seat map10M50 KB~500 GB
Booking requests5M2 KB~10 GB
Ticket delivery (email/QR)5M100 KB~500 GB
Total~1.5 TB/day

4. Data Model & Storage

Entity Relationship

erDiagram EVENT { bigint id PK varchar name varchar venue_id FK datetime event_date varchar category text description varchar status jsonb pricing_tiers datetime created_at } VENUE { bigint id PK varchar name varchar address jsonb layout_config int total_seats } SEAT { bigint id PK bigint event_id FK varchar section varchar row varchar seat_number varchar status decimal price varchar held_by datetime held_until } BOOKING { bigint id PK bigint user_id FK bigint event_id FK jsonb seat_ids decimal total_amount varchar status datetime booked_at datetime expires_at } PAYMENT { bigint id PK bigint booking_id FK varchar method decimal amount varchar status varchar transaction_id datetime paid_at } EVENT ||--o{ SEAT : "has seats" EVENT ||--o{ BOOKING : "has bookings" VENUE ||--o{ EVENT : "hosts" BOOKING ||--o| PAYMENT : "paid via"

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

flowchart TB subgraph Clients WEB[Web App] MOB[Mobile App] end subgraph Gateway["API Gateway"] LB[Load Balancer] RL[Rate Limiter] QUEUE[Queue Manager] end subgraph Services SEARCH[Event Search Service] SEAT[Seat Selection Service] BOOK[Booking Service] PAY[Payment Service] NOTIFY[Notification Service] end subgraph Storage REDIS[(Redis: Seat Inventory)] PG[(PostgreSQL)] ES[(Elasticsearch)] KAFKA[Kafka] S3[(S3: Tickets)] end WEB & MOB --> LB LB --> RL --> SEARCH & SEAT & BOOK & PAY SEARCH --> ES & PG SEAT --> REDIS BOOK --> REDIS & PG PAY --> PG BOOK --> KAFKA KAFKA --> NOTIFY NOTIFY --> S3

Component Responsibilities

ComponentResponsibilityTechnology
Event Search ServiceBrowse events, filter, autocompleteElasticsearch + PostgreSQL
Seat Selection ServiceReal-time seat map, hold seatsRedis + Lua scripts
Booking ServiceOrchestrate booking flow, manage statePostgreSQL + Redis
Payment ServiceProcess payments, handle refundsPostgreSQL + Payment gateway
Notification ServiceEmail, SMS, push for confirmationsKafka + SendGrid/Twilio
Queue ManagerVirtual waiting room for flash salesRedis + 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

stateDiagram-v2 [*] --> Available Available --> Held: User holds seat Held --> Sold: Payment confirmed Held --> Available: Hold expires (10 min) Held --> Available: User cancels hold Sold --> Refunding: User requests refund Refunding --> Available: Refund processed

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.

LayerDataTTLRefresh
CDNStatic seat layout (positions, sections)1 hourOn venue update
RedisSeat status (available/held/sold)Real-timeOn every change
WebSocketLive status updatesReal-timePush 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 Example: User A and User B both click "Book Seat A-1-15" at the same millisecond. Both read status = "available". Both attempt to write status = "held". Without atomic operations, both succeed — selling the same seat twice.

Race Condition Prevention Strategies

StrategyMechanismProsCons
Redis atomic operationsLua script + WATCHFast, simpleRedis-only, no cross-store
Database pessimistic lockSELECT FOR UPDATEStrong consistencyBlocking, reduces throughput
Database optimistic lockversion column + CASNon-blockingHigh retry rate under contention
Distributed lock (Redlock)Redlock algorithmCross-service coordinationComplex, latency overhead
Single-writer queueOne process per eventSimple, no locksSingle 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

StrategyThroughputConsistencyComplexityBest For
Redis Lua script100K+ ops/secAtomic (single key)LowHigh-throughput seat holds
Optimistic lock10K ops/secEventual (with retries)MediumModerate contention
Pessimistic lock1K ops/secStrong (serial)LowLow contention, high value
Redlock50K ops/secDistributed lockHighCross-service coordination
Single-writer queue50K ops/secStrong (serial)MediumFlash 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.

ScopeLock KeyThroughputContention
Globallock:booking:global1 booking/secMaximum
Per eventlock:booking:{eventId}1K-10K/secPer event
Per sectionlock:booking:{eventId}:{section}10K-50K/secPer section
Per seatlock:booking:{seatId}100K+/secNone
No lock (Redis atomic)N/A (Lua script)100K+/secNone
Recommendation: For most booking systems, use Redis Lua scripts for the hot path (seat hold/release) and database transactions for the booking confirmation. This combination provides atomic seat operations without the overhead of distributed locks.

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

stateDiagram-v2 [*] --> Pending Pending --> Processing: User submits payment Processing --> Completed: Gateway confirms Processing --> Failed: Gateway rejects Failed --> Pending: User retries Completed --> Refunding: Refund requested Refunding --> Refunded: Refund processed Refunding --> RefundFailed: Refund error RefundFailed --> Refunding: Retry

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.

GatewayFeaturesLatencyBest For
StripeIdempotency keys, webhooks, fraud detection200-500msGlobal platforms
PayPalVault, buyer protection, split payments300-800msMarketplaces
SquareIn-person + online, inventory sync200-400msPhysical venues
AdyenMulti-PSP, local payment methods200-600msInternational

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

flowchart TB CANCEL[Booking Cancelled] CHECK[Check Waitlist] NEXT[Get Next User] NOTIFY[Send Notification] TIMER[15-min Window] BOOK{User Books?} SKIP[Skip to Next] RELEASE[Release Seat] CANCEL --> CHECK CHECK --> NEXT NEXT --> NOTIFY NOTIFY --> TIMER TIMER --> BOOK BOOK -->|Yes| RELEASE BOOK -->|No (timeout)| SKIP SKIP --> NEXT

Queue Architecture Comparison

TechnologyThroughputDurabilityFairnessBest For
Redis Sorted Set100K+/secAOF/RDBFIFO (time-based score)Real-time queues
Kafka1M+/secDisk replicationPartition-orderedEvent-driven processing
PostgreSQL queue10K/secACIDFIFO (ID order)Durability-critical
RabbitMQ50K/secPersistent queuesFIFO with priorityComplex 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.

flowchart TB subgraph Before["Pre-Sale (T-1 hour)"] CACHE[Pre-warm Cache] SCALE[Auto-scale to 10x] QUEUE_INIT[Initialize Virtual Queue] end subgraph During["During Sale"] LB[Load Balancer] BOT[Bot Detection] VR[Virtual Room] BOOK[Booking Service] PAY[Payment] end subgraph After["Post-Sale"] ANALYZE[Analyze Metrics] SCALE_DOWN[Scale Down] end Before --> During --> After

Flash Sale Optimization Checklist

OptimizationImpactImplementation
Pre-warm Redis cache50% faster seat lookupsLoad all seat data before sale opens
Static asset CDN80% less origin trafficCache event pages, seat maps on CDN
Virtual waiting roomPrevents thundering herdQueue users, process in order
Bot detectionPrevents scalpersDevice fingerprint, CAPTCHA, rate limit
Auto-scaling rulesScale based on queue depth and CPUHandle 100x traffic spikes
Seat-level lockingMaximize throughputRedis Lua per-seat atomic operations
Payment retry queueHandle payment timeoutsAsync payment processing via Kafka
Graceful degradationKeep core flow workingDisable 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.

LayerDataTTLInvalidation
CDNEvent pages, static assets1 hourPurge on event update
Redis (search)Event search results5 minutesTTL-based
Redis (seats)Seat availability mapReal-timeWrite-through on change
Redis (user)User's held seats, cart10 minutesOn checkout/cancel
ApplicationVenue layouts, pricing tiers30 minutesOn 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));
    }
}
            
Cache Invalidation Pattern: Use write-through caching for seat inventory — when a seat status changes, update both Redis and the database atomically. This ensures the cache is always consistent with the source of truth. Never use cache-aside for seat inventory because a cache miss could lead to double-booking.

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.

flowchart LR BOOK[Booking Confirmed] KAFKA[Kafka: booking-events] EMAIL[Email Service] SMS[SMS Service] PUSH[Push Notification] TICKET[Ticket Generator] BOOK --> KAFKA KAFKA --> EMAIL & SMS & PUSH & TICKET EMAIL -->|"Confirmation email"| USER[User] SMS -->|"SMS confirmation"| USER PUSH -->|"Push notification"| USER TICKET -->|"QR code PDF"| USER

Notification Templates

EventChannelTemplateTiming
Booking confirmedEmail + SMS + PushBooking details + QR ticketImmediately
Payment receiptEmailInvoice + receiptImmediately
Event reminderEmail + PushEvent details + directions24h before event
Waitlist updateEmail + PushTickets available, 15-min windowOn availability
Booking cancelledEmail + SMSCancellation + refund statusImmediately
Hold expiringPushComplete checkout in 5 minutes5 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.

MetricDescriptionTarget
Conversion rateBookings / Event page views3-5%
Cart abandonment rateStarted checkout but didn't complete< 30%
Average booking timeTime from seat selection to confirmation< 3 minutes
Payment success rateSuccessful payments / Total attempts> 95%
Cancellation rateCancelled bookings / Total bookings< 5%
Flash sale sell-throughTickets sold in first hour / Total tickets> 80%
Revenue per eventTotal revenue / Events hostedTrending

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.

flowchart TB subgraph Normal["Normal Load"] N_APP[3 App Servers] N_DB[(1 DB Primary + 1 Replica)] N_REDIS[(Redis 3-node)] end subgraph Peak["Peak Load (10x)"] P_APP[30 App Servers] P_DB[(1 DB Primary + 4 Replicas)] P_REDIS[(Redis Cluster 12-node)] end subgraph Flash["Flash Sale (100x)"] F_APP[300 App Servers] F_DB[(Sharded DB 8 shards)] F_REDIS[(Redis Cluster 48-node)] F_QUEUE[Virtual Queue + CDN] end Normal -->|"Scale up"| Peak Peak -->|"Flash sale mode"| Flash

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 KeyDistributionHot Shard RiskBest For
event_idEven (popular events distributed)High for mega eventsEvent-centric queries
user_idEven (users distributed)LowUser-centric queries
venue_idUneven (popular venues)HighVenue management
Geographic regionUneven (population density)MediumMulti-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

MetricTargetAlert Threshold
Booking success rate> 99%< 97%
Booking latency (p99)< 2s> 5s
Payment success rate> 95%< 90%
Seat hold expiry rate< 20%> 40%
Double-booking incidents0Any 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).

SLOTargetError Budget (30 days)
Availability99.99%4.32 minutes
Booking success rate99.9%43.2 minutes of failures
Zero double-bookings100%Zero tolerance
Payment processing99.95%21.6 minutes

19. Cost Estimation

Monthly Infrastructure Cost (5M bookings/day)

ComponentSpecMonthly Cost
Application servers10 × m5.xlarge (normal), 100 × m5.xlarge (peak)~$14,000
PostgreSQL cluster4 shards × primary + 2 replicas (r5.xlarge)~$17,200
Redis cluster12 nodes × r5.xlarge~$11,400
Elasticsearch6 nodes × m5.xlarge~$4,800
Kafka cluster6 nodes × m5.xlarge~$3,400
CDN (CloudFront)10TB/month~$850
Payment gateway fees2.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 StreamRateMonthly 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 uplift10-30% price increase on high-demand eventsAdditional 10-30%
Advertising revenueEvent promotion, sponsored listings$10-50M
Data licensingAnonymized booking data for market research$5-20M
White-label platformLicensing the booking engine to venues$20-100M
Insurance productsTicket 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.

ComponentDetails
Scale30M+ tickets/month, 14M requests/minute at peak
InventoryCustom distributed inventory system
QueueVirtual waiting room with queue position
Bot protectionAdvanced bot detection + CAPTCHA
PaymentStripe + custom fraud detection
Ticket deliveryMobile-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.

ComponentDetails
Scale28M+ listings, 1.5M room nights/day
InventoryReal-time room availability from hotel PMS
OverbookingPredictive model to handle no-shows
PricingDynamic pricing based on demand, season, events
PaymentPay at hotel or pay now options
CancellationFlexible 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.

LessonDetailImpact
Separate search from bookingSearch can be eventually consistent, booking cannot10x throughput improvement
Virtual waiting rooms workTicketmaster's queue reduced server load by 90%Prevented crashes
Idempotency is criticalPayment retries without idempotency cause double chargesZero double charges
Graceful degradation winsDisable recommendations during flash salesCore flow stays up
Pre-warm everythingCache, connections, auto-scaling before sale opens50% faster cold start

21. Edge Cases

Edge CaseImpactSolution
Payment succeeds but booking failsUser charged but no ticketImmediate refund + retry booking
Hold expires during paymentSeats released mid-checkoutExtend hold when payment starts
User books same event twiceExceeds max tickets per userCheck user's existing bookings before hold
Event cancelled by organizerAll bookings need refundBatch refund + notification pipeline
Partial group booking failureSome seats held, others failedAtomic all-or-nothing hold
Duplicate payment submissionDouble chargeIdempotency key per booking
Seat map stale after venue changeWrong seat positionsVersion seat maps, invalidate on change
Timezone confusionWrong event time displayedStore all times in UTC, display in user's timezone
Currency conversion during checkoutPrice changes between selection and paymentLock price at hold time, display disclaimer
Accessibility seat requirementsWheelchair spaces double-bookedSeparate inventory for accessible seating

22. Interview Q&A

Q1: How do you prevent double-booking the same seat?

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.

Q2: How do you handle 14 million concurrent users for a flash sale?

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.

Q3: What happens if payment succeeds but the booking confirmation fails?

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.

Q4: How do you design the seat selection UI to show real-time availability?

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.

Q5: How do you handle dynamic pricing during high demand?

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.

Q6: How would you design the waitlist system for a sold-out event?

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.

Q7: How do you handle the database bottleneck during flash sales?

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%.

Q8: How do you ensure fairness when multiple users try to book the same seats?

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

StepTicket Booking Approach
Requirements5M bookings/day, zero double-bookings, 2s p99 latency
Back-of-envelope5,800 QPS avg, 100K QPS flash sale, 5TB/year storage
Data modelEvents, seats (with status), bookings, payments
API designBrowse events, hold seats, confirm booking, cancel
ArchitectureRedis (inventory) + PostgreSQL (bookings) + Kafka (events)
Deep diveAtomic seat holds, distributed locking, payment saga
ReliabilityIdempotency, 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

MetricValue
Redis Lua script throughput100K+ atomic operations/sec
Optimal hold duration10 minutes (balance inventory turnover vs UX)
Virtual room processing rate1,000 users simultaneously
Payment gateway timeout30 seconds max
Waitlist notification window15 minutes to respond
Double-booking toleranceZero (absolute requirement)
Flash sale peak QPS100,000 bookings/second
Seat map WebSocket update latency< 100ms
Seat hold TTL10 minutes
Waitlist notification window15 minutes to complete booking
Payment gateway timeout30 seconds max before retry
Virtual room capacity1,000 concurrent users in booking flow
Flash sale sell-through target80%+ 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 time3-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 SLA99.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

MetricDescriptionTarget
Conversion RatePage views to confirmed bookings3-8%
Checkout AbandonmentStarted checkout but didn't complete< 40%
Avg Booking TimeTime from seat selection to confirmation< 3 minutes
Revenue per EventTotal ticket revenue per eventVaries
Seat UtilizationSold seats / total available seats> 80%
Flash Sale Sell-ThroughTickets 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

MetricTargetDescription
Double-Charge Rate0Zero duplicate payments due to idempotency
Idempotency Key TTL24 hoursDuration to cache and deduplicate requests
Lock Timeout30 secondsMaximum hold time for distributed seat lock
Payment Reconciliation Lag< 5 minutesTime to detect and reconcile payment mismatches

Ayodhyya — System Design Blog Series

Ticket Booking System — Senior+ Guide