System Design Problem

Design a Ticketing System (BookMyShow / Ticketmaster)

Commonly Asked By:TicketmasterAmazonGoogle

Interview Setup

Interview Prompt

Design an online ticketing system like Ticketmaster. Users browse events, select seats from a venue map, and purchase tickets. Prevent double booking when thousands of users compete for the same seats during a popular on-sale.

Clarifying Questions (ask before designing)

QuestionWhy it matters
Reserved seating or general admission?Reserved seating requires seat level locking with an interactive venue map, whereas general admission uses a shared inventory counter and does not need one lock per physical seat.
On-sale spike: how many concurrent users compete during hot events?Handling 500,000 users competing for 20,000 seats produces extreme contention, which drives the need for a virtual waiting room, bounded admission, and granular seat locking.
Lock duration: how long can a user hold seats before completing payment?A 7 to 10 minute window is a common product choice. A shorter window increases checkout failures, while a longer window keeps seats unavailable during a sellout event.
Optimistic or pessimistic locking acceptable?Pessimistic row locking provides strong safety but can serialize hot seat access. Optimistic locking works under lighter contention but can produce a large retry storm during major on-sales.

Scope

In scope

  • Venue seat map and availability model
  • Seat locking with TTL during checkout
  • Double booking prevention under concurrency
  • Waiting queue and virtual waiting room for hot on-sales
  • Order completion and ticket issuance

Out of scope (state explicitly)

  • Payment provider internals and payment network processing
  • Secondary resale marketplace full design
  • Venue access control and barcode scanning at gate
  • Full production dynamic pricing strategy and optimization

Functional Requirements

Start by asking your interviewer about reserved vs general admission seating, hold duration during checkout, and cancellation policies. Virtual waiting rooms for hot on-sales are almost always discussed.

  • Browse events: Search and browse movies, concerts, and sports events by location, date, and genre.
  • View available seats: Display the venue seat map with real time availability states across available, held, and booked seats.
  • Select seats: Support specific seat selection for reserved seating, or ticket quantity selection for general admission.
  • Temporary hold: Selected seats are temporarily held for 5 to 10 minutes while the user completes checkout.
  • Book and Pay: Complete booking through integration with a payment gateway.
  • Booking confirmation: Issue digital tickets with QR codes through email and mobile app notifications.
  • Cancel and Refund: Cancel bookings and process customer refunds according to event cancellation policies.
  • Waiting list: Allow users to join a priority waiting list when a show is completely sold out.

Non-Functional Requirements

Interviewers focus heavily on preventing double booking in this problem. Strong consistency on seat holds drives the concurrency strategy and should be stated before drawing the venue seat map.

  • Strong Consistency: Two users must not book the same seat, which is the foremost constraint of the system.
  • High Availability: 99.99% system availability for event browsing and queue admission.
  • Low Latency: Seat availability map loads in under 200 ms.
  • Handle Traffic Spikes: Popular events such as stadium tours or movie premieres can drive over 1M users hitting the system simultaneously.
  • Fair Booking: Queue admission controls and randomized opening ranks prevent automated clients from consuming inventory ahead of admitted users.
  • Idempotent Operations: Network retries and duplicate callbacks must never produce duplicate seat bookings or double charges.
  • Scalability: Support 1,000 or more concurrent events and over 10M bookings per day during peak seasons.

Capacity Estimations

Run this math before you pick a locking strategy. Seats per event and concurrent buyers during on-sale tell you whether Redis atomic holds plus a virtual queue are mandatory.

MetricCalculationValue
Active events at any timeGiven (assumption documented in value)50K
Seats per event (avg)Given (typical workload assumption)500
Total bookable seatsGiven (assumption documented in value)25M
Bookings / dayGiven5M
Peak bookings / sec (popular event on-sale)Given (peak on-sale spike, not avg)50K
Seat checks / sec (availability)Given (peak load assumption)500K
Temporary holds active at peakGiven (peak load assumption)1M
Scale challenge: 1M users hitting 'Buy' at 10:00:00 AM.
Needs Virtual Queue to protect the transactional database.

Architecture Diagram

In the room: state your concurrency strategy for hot on-sales before drawing the seat map.

We hold seats with short lived reservations, confirm them after successful payment capture, and release them when their hold expires. The lifecycle is coordinated through an atomic reservation workflow with PostgreSQL as the authoritative inventory store and Redis as the fast reservation gate.

Loading...

Component Deep Dives

1. Complete Booking Flow: Step-by-Step Sequence

Seat inventory locking forms the central challenge of the system. We walk through reservation, payment, and release flows before examining supporting services.

Loading...

2. Seat Lifecycle and States

Each seat transitions through available, held, and booked states. Defining this lifecycle upfront clarifies concurrency boundaries before addressing database locks.

Loading...

3. API Gateway: First Line of Defense

Centralize authentication, rate limiting, and traffic routing before incoming requests reach downstream services.

  • Bot detection: Use TLS signals, JavaScript challenges, device signals, and behavioral checks. Avoid relying on a single browser fingerprint because determined automation can mimic browser clients.
  • Rate limiting: Enforce token bucket limits per IP and per authenticated user on booking endpoints. Example limits are 100 requests per minute per IP and 50 requests per minute per authenticated user.
  • Virtual queue gate: For high demand events, route booking attempts through the Virtual Queue Service instead of allowing direct access to the Booking Service.
  • JWT validation: Verify cryptographic queue tokens and confirm valid event identifiers, user identity, queue issuance time, and expiration.

4. Event and Search Service

Event browsing is read heavy, so show metadata is indexed separately from the transactional seat inventory database.

  • Catalog management: Manage CRUD operations for events, venues, shows, and tiered pricing configurations.
  • Search indexing: Elasticsearch clusters index event titles, categories, cities, venue locations, artist names, and search tags.
  • Metadata caching: Redis caches frequently accessed event metadata such as posters, descriptions, and show schedules with a 5 minute TTL.
  • Catalog persistence: A relational database stores structured event hierarchies and uses read replicas for browse traffic. Inventory state remains separate from catalog state.

5. General Admission Inventory Path

For general admission events there is no physical seat row to lock. The system maintains a durable inventory count in PostgreSQL and uses an atomic Redis counter as a fast admission gate. The same virtual waiting room limits the number of buyers allowed to decrement inventory concurrently, and the final inventory transition is committed transactionally so retries cannot allocate more tickets than remain available.

6. Booking Service: Multi Seat Atomic Hold (Lua Script)

Multiple seat holds must be atomic so that a Lua script in Redis prevents partial reservations. When a user selects several seats, all must be acquired atomically. If any single seat is unavailable, none are reserved.

LUA
-- Redis Lua script: atomic multi-seat hold
-- KEYS = list of seat lock keys for the same show
-- ARGV[1] = hold_id, ARGV[2] = TTL in seconds

-- Phase 1: Check all seats are available
for i, key in ipairs(KEYS) do
    if redis.call('EXISTS', key) == 1 then
        return {0, key}  -- FAIL: seat already held
    end
end

-- Phase 2: Lock all seats atomically
for i, key in ipairs(KEYS) do
    redis.call('SET', key, ARGV[1], 'EX', ARGV[2])
end

return {1, 'OK'}  -- SUCCESS: all seats held

Why Lua script? Redis executes Lua scripts atomically within its single threaded execution context. No conflicting command can interleave between the initial checks and the subsequent set operations on that shard. All seat keys use the same Redis hash tag for a show so a multiple seat hold stays within one cluster slot.

Redis is only an admission gate. PostgreSQL performs the authoritative hold transition and updates shows.available_seats in the same transaction as the seat rows and hold record.

SQL
BEGIN;

-- 1. Lock the requested seats in deterministic order
SELECT seat_id, status, held_until
FROM seats
WHERE show_id = $1
  AND seat_id = ANY($2)
ORDER BY seat_id
FOR UPDATE;

-- 2. Verify every requested seat is available or has an expired hold
--    Count seats that were already available before this transition.

-- 3. Transition the requested seats to HELD
UPDATE seats
SET status = 'held',
    held_by = $3,
    hold_id = $4,
    held_until = NOW() + INTERVAL '10 minutes',
    version = version + 1
WHERE show_id = $1
  AND seat_id = ANY($2)
  AND (status = 'available' OR held_until < NOW());

-- 4. Verify row count = requested seat count

-- 5. Decrement only seats that were actually available before the transition
UPDATE shows
SET available_seats = available_seats - $5
WHERE show_id = $1
  AND available_seats >= $5;

-- 6. Insert the hold and its transactional outbox event
-- 7. Commit
COMMIT;

7. Booking Confirmation: Database Transaction and Payment State

Payment authorization happens outside the PostgreSQL transaction. The database transaction promotes the valid Redis backed hold into a durable booking state. The previously persisted payment authorization is verified from local payment state before the transaction, and capture happens after the database commit. This avoids holding a database transaction open across a third party payment call.

SQL
BEGIN;

-- 1. Verify the hold and lock its row
SELECT hold_id, user_id, show_id, seat_ids, quoted_total_amount, hold_expires_at
FROM seat_holds
WHERE hold_id = $1
  AND user_id = $2
  AND status = 'active'
  AND hold_expires_at > NOW()
FOR UPDATE;

-- 2. Authorization was already persisted by the Payment Service before this transaction.
--    Do not make a network call to the Payment Service or external PSP while this transaction is open.

-- 3. Promote every held seat owned by this hold to BOOKED
UPDATE seats
SET status = 'booked',
    booked_by = $2,
    booking_id = $3,
    version = version + 1
WHERE show_id = $4
  AND seat_id = ANY($5)
  AND status = 'held'
  AND hold_id = $1
  AND held_by = $2;

-- 4. Verify row count = expected seat count

-- 5. Persist the booking as pending_capture
INSERT INTO bookings (
    booking_id, user_id, show_id, hold_id, seat_ids,
    total_amount, payment_id, status, idempotency_key, booked_at
)
VALUES (
    $3, $2, $4, $1, $5,
    $6, $7, 'pending_capture', $8, NOW()
);

-- 6. Record the booking event in the outbox
INSERT INTO booking_outbox (event_id, booking_id, event_type, payload, created_at)
VALUES ($10, $3, 'pending_capture', $11, NOW());

-- 7. Insert the audit record
INSERT INTO booking_audit_log (booking_id, action, actor, details, created_at)
VALUES ($3, 'BOOKING_COMMITTED_PENDING_CAPTURE', $2, '{"seats": ...}', NOW());

-- 8. Record the seat status outbox event in the same transaction
INSERT INTO seat_status_outbox (event_id, show_id, seat_ids, status, created_at)
VALUES ($9, $4, $5, 'booked', NOW());

COMMIT;

-- Outside the transaction:
-- 9. Capture payment using an idempotency key.
-- 10. On capture success, mark booking confirmed and write the confirmed booking event outbox record.
-- 11. On capture failure, release the booking or start the compensation workflow.

Using a pending_capture state closes the sequencing gap between database commit and payment capture. A reconciliation worker handles ambiguous capture outcomes by querying the payment provider and then converges the booking to confirmed, cancelled, or refunded.

8. Virtual Queue Service: Handling 1M Simultaneous Users

The virtual queue provides admission control before traffic reaches the booking service during high demand on-sales. For hot events, users enter a queue backed by Redis. A background worker drains the queue at a controlled rate matched to database capacity, issuing a signed JWT token required for booking.

PYTHON
# Queue drainer worker loop
while True:
    # Pop the next batch of users whose turn has arrived
    users = redis.zpopmin(f"queue:{event_id}", count=BATCH_SIZE)

    for user_id, score in users:
        token = jwt.encode({
            "user_id": user_id,
            "event_id": event_id,
            "issued_at": now(),
            "expires_at": now() + timedelta(minutes=10),
            "max_tickets": 4
        }, SECRET_KEY)

        # Notify the user. The client can also poll queue status.
        push_notification(user_id, {"status": "your_turn", "booking_token": token})

    sleep(BATCH_SIZE / DRAIN_RATE)

9. Seat Map Service: Real Time Availability Push

Pushing seat map updates over WebSockets lets users see holds and expirations in real time. The Booking Service records the authoritative state change and its outbox event, then Kafka fans updates out to WebSocket gateway instances. Kafka preserves ordering for a show within the seat status topic, and clients can refresh the full seat map after reconnect or when an update gap is detected.

JSON
{
  "type": "seat_update",
  "show_id": "show-uuid",
  "updates": [
    {"seat_id": "P-A1", "status": "held"},
    {"seat_id": "P-A2", "status": "held"}
  ]
}

10. Payment Service: Authorize, Capture, and Reconcile

Idempotency keys and separate authorization and capture phases prevent duplicate charges during network retries while avoiding external payment calls inside database transactions. If the hold is no longer valid after authorization, the authorization is voided and no booking is created.

  • Authorize first: The Payment Service authorizes the amount against the hold before the booking transaction. The authorization result is persisted locally and can be checked by the Booking Service before the seat transaction begins.
  • Capture after commit: The Booking Service commits the seat state and booking as pending_capture, then calls capture with the same idempotency key.
  • Reconcile unknown outcomes: If capture times out, a reconciliation worker queries the payment provider before deciding whether to confirm, void, refund, or release the booking.
  • Idempotent execution: Every payment attempt carries an idempotency key tied to the hold_id and request hash, so retries return the original result instead of creating a second charge.

11. Hold Expiry Worker: Background Cleanup

Background workers poll for expired holds and restore unpurchased seats to available inventory. The availability count is increased only for seats that the worker actually transitions from held to available. Cleanup must be idempotent because multiple workers can observe the same expiration.

PYTHON
# Runs every 30 seconds
def cleanup_expired_holds():
    db.begin_transaction()

    expired = db.query("""
        SELECT hold_id, show_id, seat_ids
        FROM seat_holds
        WHERE status = 'active' AND hold_expires_at < NOW()
        FOR UPDATE SKIP LOCKED
        LIMIT 500
    """)

    redis_releases = []

    for hold in expired:
        changed = db.execute("""
            UPDATE seat_holds
            SET status = 'expired'
            WHERE hold_id = %s
              AND status = 'active'
              AND hold_expires_at < NOW()
        """, hold.hold_id)

        if changed.rowcount != 1:
            continue

        released_ids = db.query("""
            UPDATE seats
            SET status = 'available',
                held_by = NULL,
                hold_id = NULL,
                held_until = NULL,
                version = version + 1
            WHERE show_id = %s
              AND hold_id = %s
              AND status = 'held'
            RETURNING seat_id
        """, hold.show_id, hold.hold_id)

        db.execute("""
            UPDATE shows
            SET available_seats = available_seats + %s
            WHERE show_id = %s
        """, len(released_ids), hold.show_id)

        if released_ids:
            db.execute("""
                INSERT INTO seat_status_outbox (event_id, show_id, seat_ids, status, created_at)
                VALUES (%s, %s, %s, 'available', NOW())
            """, uuid4(), hold.show_id, released_ids)
            redis_releases.append((hold.show_id, hold.hold_id, released_ids))

    db.commit()

    for show_id, hold_id, released_ids in redis_releases:
        # Redis locks are removed only when the stored value still matches hold_id.
        redis.release_matching_locks(show_id, released_ids, hold_id)

12. Notification Service

Dedicated notification workers isolate third party delivery channels and retry logic.

  • Consumes booking-events and notification-events from Kafka.
  • Dispatches digital ticket QR codes, booking confirmations, and cancellation notices across email, SMS, and mobile push notifications.
  • Enforces idempotency on booking_id and notification type so a replayed event does not send duplicate customer messages.
  • Customer booking confirmations and ticket delivery are triggered only by the confirmed booking event. pending_capture is an internal reconciliation state and must not generate a confirmed-ticket notification.

13. Event Bus Design (Kafka)

The event bus decouples producers from consumers and buffers asynchronous traffic spikes across the platform.

YAML
Topic: seat-status-changes
  Partitions: 64 (partition by show_id so all seat deltas for one show remain ordered)
  Retention: 24h (supports real-time seat maps while analytics sinks to data warehouse)
  Producers: Seat Status Outbox Publisher after Booking Service or Hold Expiry Worker commits state
  Consumers: Seat Map Service (WebSocket fan-out), availability cache updater, analytics

Topic: booking-events
  Partitions: 32 (partition by booking_id)
  Retention: 30 days
  Producers: Booking Service outbox publisher (pending_capture, confirmed, cancelled)
  Consumers: Notification Service, waitlist consumer, analytics (ClickHouse)

Topic: payment-events
  Partitions: 16 (partition by payment_id)
  Producers: Payment Service outbox publisher after local payment state commits (authorized, captured, voided, refunded)
  Consumers: Booking Service, reconciliation workers

Topic: notification-events
  Partitions: 16 (partition by user_id)
  Producers: Notification Service
  Consumers: email/SMS/push workers

Replication factor: 3, min.insync.replicas: 2, producer acks: all
Reliability pattern: PostgreSQL transaction writes authoritative seat state and an outbox record atomically. The outbox publisher delivers Kafka events at least once, and downstream consumers deduplicate by event_id.
Sync path: Redis atomic pre-gate → PostgreSQL authoritative hold transaction → return hold_id
Async path: seat map push, analytics, notifications, search updates

API Design

Service Interfaces and Domain Types

The core API manages event discovery, queue admission, atomic seat holds, and idempotent checkout confirmation. Domain types make the contracts explicit.

TYPESCRIPT
export type UUID = string;
export type SeatId = string;

export type SeatStatus = "available" | "held" | "booked";

export interface Money {
  amountMinor: number;
  currency: string;
}

export interface Seat {
  seatId: SeatId;
  section: string;
  row: string;
  seatNumber: number;
  status: SeatStatus;
  price: Money;
}

export interface EventSummary {
  eventId: UUID;
  title: string;
  category: string;
  city: string;
  venueName: string;
}

export interface ShowDetails {
  eventId: UUID;
  title: string;
  shows: ShowSummary[];
}

export interface ShowSummary {
  showId: UUID;
  showTime: string;
  availableSeats: number;
}

export interface SeatMapAvailability {
  showId: UUID;
  layoutVersion: number;
  seats: Seat[];
}

export interface QueuePosition {
  queueId: UUID;
  position: number;
  estimatedWaitSeconds: number;
}

export interface QueueStatusToken {
  status: "waiting" | "your_turn" | "expired";
  bookingToken?: string;
  maxTickets?: number;
}

export interface HoldSeatsRequest {
  showId: UUID;
  seatIds: SeatId[];
  bookingToken: string;
}

export interface HoldSeatsResponse {
  holdId: UUID;
  showId: UUID;
  seatIds: SeatId[];
  quotedTotal: Money;
  expiresAt: string;
  ttlSeconds: number;
}

export interface ConfirmBookingRequest {
  holdId: UUID;
  paymentMethodId: UUID;
}

export interface BookingConfirmation {
  bookingId: UUID;
  userId: UUID;
  showId: UUID;
  seatIds: SeatId[];
  totalAmount: Money;
  paymentId: UUID;
  status: "pending_capture" | "confirmed" | "failed";
  ticketQrUrls?: string[];
  confirmedAt?: string;
}

export interface CancellationResult {
  status: "cancelled";
  refundedAmount: Money;
}

export interface TicketingService {
  searchEvents(city: string, category?: string, query?: string): Promise<EventSummary[]>;
  getShowDetails(eventId: UUID): Promise<ShowDetails>;
  getSeatMap(eventId: UUID, showId: UUID): Promise<SeatMapAvailability>;
  joinVirtualQueue(eventId: UUID, showId: UUID): Promise<QueuePosition>;
  pollQueueStatus(queueId: UUID): Promise<QueueStatusToken>;
  holdSeats(request: HoldSeatsRequest, idempotencyKey: string): Promise<HoldSeatsResponse>;
  confirmBooking(request: ConfirmBookingRequest, idempotencyKey: string): Promise<BookingConfirmation>;
  cancelBooking(bookingId: UUID, idempotencyKey: string): Promise<CancellationResult>;
}

Search Events

HTTP
GET /api/v1/events?city=mumbai&category=movie&date=2026-03-13&q=avengers&sort=popularity&page=1&limit=20

Get Event Details and Show Times

HTTP
GET /api/v1/events/{event_id}
Response: 200 OK
{
  "event_id": "evt-uuid",
  "title": "Avengers: Secret Wars",
  "shows": [
    {
      "show_id": "show-uuid-1",
      "show_time": "2026-03-13T14:00:00+05:30",
      "available_seats": 125,
      "sections": [
        {"name": "Premium", "price": 800, "available": 20, "total": 50}
      ]
    }
  ]
}

Get Seat Map (Real Time)

HTTP
GET /api/v1/events/{event_id}/shows/{show_id}/seats

Join Virtual Queue

HTTP
POST /api/v1/queue/join
{
  "event_id": "evt-uuid",
  "show_id": "show-uuid"
}
Response: 200 OK
{
  "queue_id": "q-uuid",
  "position": 4523,
  "estimated_wait_seconds": 180
}

Poll Queue Status

HTTP
GET /api/v1/queue/status/{queue_id}
Response (your turn): 200 OK
{
  "status": "your_turn",
  "booking_token": "eyJhbGciOiJIUzI1NiIs...",
  "max_tickets": 4
}

Hold Seats

HTTP
POST /api/v1/bookings/hold
Idempotency-Key: hold-idem-uuid-123
{
  "show_id": "show-uuid",
  "seat_ids": ["P-A1", "P-A4"],
  "booking_token": "eyJhbGciOiJIUzI1NiIs...",
  "idempotency_key": "hold-idem-uuid-123"
}

Confirm Booking (Pay and Book)

HTTP
POST /api/v1/bookings/confirm
Idempotency-Key: idem-uuid-12345
{
  "hold_id": "hold-uuid",
  "payment_method_id": "pm-uuid"
}

Cancel Booking

HTTP
POST /api/v1/bookings/{booking_id}/cancel
Idempotency-Key: cancel-idem-uuid-123

Join Waitlist

HTTP
POST /api/v1/waitlist
{
  "show_id": "show-uuid",
  "requested_seats": 2,
  "preferred_section": "Premium"
}

Get User's Bookings

HTTP
GET /api/v1/users/me/bookings?status=confirmed&page=1

Payment Service APIs (Internal and Gateway)

HTTP
POST /api/v1/internal/payments/authorize
Idempotency-Key: hold-uuid
{
  "hold_id": "hold-uuid",
  "amount_minor": 29999,
  "payment_method": "credit_card",
  "idempotency_key": "hold-uuid"
}
Response: 200 OK
{ "status": "authorized", "authorization_id": "auth-123" }

POST /api/v1/internal/payments/capture
Idempotency-Key: hold-uuid
{ "payment_id": "pay-uuid" }
Response: 200 OK
{ "status": "captured" }

POST /api/v1/internal/payments/void
Idempotency-Key: hold-uuid
{ "payment_id": "pay-uuid" }
Response: 200 OK
{ "status": "voided" }

POST /api/v1/payments/{payment_id}/refund
Idempotency-Key: refund-uuid
{ "reason": "user_cancelled" }
Response: 200 OK
{ "status": "refunded" }

Common Error Responses

JSON
400 Bad Request: invalid input, missing required fields, or malformed JSON payload
401 Unauthorized: missing or invalid authentication token or API key
403 Forbidden: authenticated caller lacks required permissions for this resource
404 Not Found: requested resource ID does not exist
409 Conflict: duplicate write or version conflict, retry with a unique idempotency key
422 Unprocessable Entity: syntactically valid request failed semantic business validation
429 Too Many Requests: rate limit quota exceeded, client should honor Retry-After header
500 Internal Error: unexpected server failure, retry safely with an idempotency key
503 Service Unavailable: downstream dependency is unavailable or overloaded, retry with exponential backoff
402 Payment Required: account balance or payment method has insufficient funds
502 Bad Gateway: payment gateway provider timeout, poll transaction status endpoint

Data Model

PostgreSQL: Venues and Sections

Event catalog metadata may use MySQL while seat inventory uses PostgreSQL. Cross database references such as venue_id are validated at the service boundary because a relational foreign key cannot span databases.

SQL
CREATE TABLE venues (
    venue_id        UUID PRIMARY KEY,
    name            VARCHAR(256) NOT NULL,
    capacity        INT,
    layout_svg_url  TEXT
);

CREATE TABLE venue_sections (
    section_id      UUID PRIMARY KEY,
    venue_id        UUID NOT NULL,
    name            VARCHAR(64) NOT NULL,
    total_seats     INT NOT NULL,
    FOREIGN KEY (venue_id) REFERENCES venues(venue_id),
    UNIQUE (venue_id, name)
);

MySQL: Event Catalog

The catalog database owns event metadata. Elasticsearch remains the serving index for event search, so relational full text indexing is not required here.

SQL
CREATE TABLE events (
    event_id        CHAR(36) PRIMARY KEY,
    title           VARCHAR(256) NOT NULL,
    artist_name     VARCHAR(256),
    category        VARCHAR(64),
    city            VARCHAR(128),
    venue_id        CHAR(36) NOT NULL,
    is_hot          BOOLEAN DEFAULT FALSE,
    status          VARCHAR(20) DEFAULT 'upcoming'
);

PostgreSQL: Shows, Seats and Holds

Show inventory belongs to the same PostgreSQL transaction domain as seats because available seat counts and seat state transitions must commit together. The event_id and venue_id values reference catalog records by identifier at the service boundary.

Inventory count invariant: shows.available_seats tracks seats whose authoritative state is available. A successful hold decrements the count only for seats that were available at the start of the transaction. Reclaiming an already expired held seat does not decrement it again, and expiry or cancellation increments the count only for seats actually returned to available.

SQL
CREATE TABLE shows (
    show_id         UUID PRIMARY KEY,
    event_id        UUID NOT NULL,
    venue_id        UUID NOT NULL,
    show_time       TIMESTAMP NOT NULL,
    available_seats INT NOT NULL CHECK (available_seats >= 0)
);

PostgreSQL: Seats and Holds

SQL
CREATE TABLE seats (
    seat_id         VARCHAR(16),
    show_id         UUID NOT NULL,
    section_name    VARCHAR(64),
    row_label       VARCHAR(8),
    seat_number     INT,
    status          VARCHAR(12) NOT NULL DEFAULT 'available',
    held_by         UUID,
    hold_id         UUID,
    held_until      TIMESTAMP,
    booked_by       UUID,
    booking_id      UUID,
    version         BIGINT NOT NULL DEFAULT 0,
    PRIMARY KEY (show_id, seat_id),
    CONSTRAINT chk_status CHECK (status IN ('available', 'held', 'booked'))
);

CREATE TABLE seat_holds (
    hold_id             UUID PRIMARY KEY,
    user_id             UUID NOT NULL,
    show_id             UUID NOT NULL,
    seat_ids            TEXT[] NOT NULL,
    quoted_total_amount DECIMAL(10,2) NOT NULL,
    status              VARCHAR(20) DEFAULT 'active',
    hold_expires_at     TIMESTAMP NOT NULL,
    idempotency_key     VARCHAR(128) NOT NULL,
    UNIQUE (user_id, idempotency_key)
);

PostgreSQL: Bookings, Tickets, and Audit

SQL
CREATE TABLE bookings (
    booking_id      UUID PRIMARY KEY,
    user_id         UUID NOT NULL,
    show_id         UUID NOT NULL,
    hold_id         UUID NOT NULL UNIQUE,
    seat_ids        TEXT[] NOT NULL,
    total_amount    DECIMAL(10,2) NOT NULL,
    payment_id      UUID,
    status          VARCHAR(20) DEFAULT 'pending_capture',
    idempotency_key VARCHAR(128) NOT NULL,
    booked_at       TIMESTAMP DEFAULT NOW(),
    UNIQUE (user_id, idempotency_key)
);

CREATE TABLE tickets (
    ticket_id       UUID PRIMARY KEY,
    booking_id      UUID NOT NULL REFERENCES bookings,
    show_id         UUID NOT NULL,
    seat_id         VARCHAR(16) NOT NULL,
    qr_payload      TEXT NOT NULL,
    qr_code_url     TEXT,
    status          VARCHAR(20) DEFAULT 'active'
);

CREATE UNIQUE INDEX uq_active_ticket_seat
    ON tickets (show_id, seat_id)
    WHERE status = 'active';

CREATE TABLE booking_audit_log (
    audit_id        UUID PRIMARY KEY,
    booking_id      UUID NOT NULL REFERENCES bookings,
    action          VARCHAR(64) NOT NULL,
    actor           UUID,
    details         JSONB,
    created_at      TIMESTAMP DEFAULT NOW()
);

PostgreSQL: Seat Status Outbox

The outbox records seat changes in the same database transaction as the authoritative seat update. A separate publisher sends those records to Kafka and can safely retry because consumers deduplicate by event_id.

SQL
CREATE TABLE seat_status_outbox (
    event_id        UUID PRIMARY KEY,
    show_id         UUID NOT NULL,
    seat_ids        TEXT[] NOT NULL,
    status          VARCHAR(20) NOT NULL,
    created_at      TIMESTAMP DEFAULT NOW(),
    published_at    TIMESTAMP
);

PostgreSQL: Booking Outbox

The booking outbox applies the same transactional event pattern to booking lifecycle events, so a committed booking state cannot be separated from its Kafka notification by an application crash.

SQL
CREATE TABLE booking_outbox (
    event_id        UUID PRIMARY KEY,
    booking_id      UUID NOT NULL REFERENCES bookings,
    event_type      VARCHAR(32) NOT NULL,
    payload         JSONB NOT NULL,
    created_at      TIMESTAMP DEFAULT NOW(),
    published_at    TIMESTAMP
);

PostgreSQL: Payments

SQL
CREATE TABLE payments (
    payment_id        UUID PRIMARY KEY,
    hold_id           UUID NOT NULL,
    booking_id        UUID REFERENCES bookings,
    user_id           UUID NOT NULL,
    amount            DECIMAL(10,2) NOT NULL,
    currency          VARCHAR(3) DEFAULT 'INR',
    payment_method    VARCHAR(32),
    gateway           VARCHAR(32),
    gateway_txn_id    VARCHAR(128),
    authorization_id  VARCHAR(128),
    status            VARCHAR(20),            -- 'authorized', 'capture_pending', 'captured', 'voided', 'refunded', 'unknown'
    idempotency_key   VARCHAR(128) UNIQUE,
    created_at        TIMESTAMP DEFAULT NOW(),
    captured_at       TIMESTAMP
);

PostgreSQL: Payment Outbox

The Payment Service records locally persisted payment state and its Kafka event atomically in its own database transaction. The outbox publisher retries safely so a persisted authorization or capture result cannot be lost because the application crashes before publishing the event. If the external capture succeeds but the process fails before local payment state is persisted, reconciliation with the payment provider resolves the ambiguous outcome.

SQL
CREATE TABLE payment_outbox (
    event_id          UUID PRIMARY KEY,
    payment_id        UUID NOT NULL REFERENCES payments,
    event_type        VARCHAR(32) NOT NULL,
    payload           JSONB NOT NULL,
    created_at        TIMESTAMP DEFAULT NOW(),
    published_at      TIMESTAMP
);

Redis Structure

YAML
# Seat holds
Key:    seat:{show_id}:{seat_id}:lock
Value:  {hold_id}
TTL:    600 (10 minutes)
Note:   {show_id} is a Redis Cluster hash tag so all seats for one show share a slot.

# Virtual Queue Sorted Set
Key:    queue:{event_id}:{show_id}
Members: user_id
Scores:  lottery_rank or join_timestamp_microseconds

# Caches
Key:    seats:avail:set:{show_id} (cache of available seats)
Key:    event:meta:{event_id} (JSON string metadata)

Kafka Topics

YAML
Topic: seat-status-changes        (partition key: show_id)
Topic: booking-events             (partition key: booking_id)
Topic: payment-events             (partition key: payment_id)
Topic: notification-events        (partition key: user_id)

Elasticsearch Event Index Schema

JSON
{
  "event_id": "evt-uuid",
  "title": "Avengers: Secret Wars",
  "category": "movie",
  "city": "Mumbai",
  "venue_name": "PVR IMAX",
  "status": "on_sale",
  "popularity_score": 9500,
  "location": { "lat": 18.9944, "lon": 72.8265 }
}

Fault Tolerance

Thundering herd on on-sale events, reservation timeout races, and payment gateway timeouts are the main failure modes.

ConcernSolution
DB replicationPostgreSQL synchronous standby with automated failover provides near-zero RPO for acknowledged booking writes while the standby is healthy
Redis Cluster3 masters and 3 replicas for seat locks, providing automatic failover if a master dies
Kafka durabilityReplication factor 3, minimum in-sync replicas 2, and acks=all provide durable event replication while consumers remain idempotent
Idempotency keysEnforced on hold, booking confirmation, and payment APIs so client retries do not create duplicate reservations, tickets, or charges
Circuit breakerConfigured between Booking Service and Payment Gateway so external payment outages fail fast while hold expiry and reconciliation protect seat inventory
Health checksLoad balancer actively monitors all service instances every 5 seconds
Multi-AZ deploymentAll critical services deployed across 3 availability zones

1. Double Booking Prevention

Four layers of defense protect seat integrity.

  1. Redis atomic gate: Pre-locks all requested seats atomically before the database transaction.
  2. Database conditional update: The hold transaction verifies current status, hold_id, owner, and expiration before transitioning seats. The version is incremented for each successful transition but is not used as the primary compare condition in this path.
  3. Database primary key: The primary key on (show_id, seat_id) ensures each physical seat has one authoritative row.
  4. Idempotency and reconciliation: Hold, booking, payment, and callback retries converge on one result through idempotency keys and background reconciliation.

2. Hold Not Released (Cleanup)

  • Primary: Redis keys automatically expire using a 10 minute TTL.
  • Secondary: The Hold Expiry Worker marks expired database holds exactly once and releases only seats still tied to that hold_id.
  • Recovery: If Redis state and PostgreSQL state disagree, PostgreSQL remains authoritative and invalid Redis locks are evicted.

3. Payment Capture Outcome Is Unknown

The booking remains in pending_capture until the Payment Service confirms the capture result. A reconciliation worker queries the payment provider with the same idempotency key. If capture succeeded, the booking becomes confirmed. If capture failed before funds moved, the booking is cancelled and seats are released. If the provider confirms a charge but the application missed the response, the system records the successful payment and completes booking through an idempotent recovery path.

4. Payment Gateway Timeout

The booking remains in pending_capture rather than being treated as an immediate failure. A background worker polls the provider API and uses the idempotency key to determine whether the request was authorized, captured, voided, or refunded before any inventory state is changed again.

Additional Considerations

1. Contiguous Seat Selection Algorithm

Find group seating while prioritizing center placement and lower rows. The seat schema stores explicit row and seat number fields so the algorithm does not need to parse presentation identifiers.

PYTHON
def find_best_contiguous_seats(show_id: str, section: str, count: int) -> list[Seat]:
    rows = db.query("""
        SELECT row_label, seat_id, seat_number, status
        FROM seats
        WHERE show_id = %s AND section_name = %s
        ORDER BY row_label ASC, seat_number ASC
    """, show_id, section)

    rows_by_label = group_by_row(rows)
    candidates = []

    for row_label, seats_in_row in rows_by_label.items():
        current_run = []

        for seat in seats_in_row:
            if seat.status == 'available':
                current_run.append(seat)
            else:
                if len(current_run) >= count:
                    candidates.append(current_run[:])
                current_run = []

        if len(current_run) >= count:
            candidates.append(current_run[:])

    if not candidates:
        return []

    def row_rank(label: str) -> int:
        return excel_row_to_int(label)  # supports A..Z and AA..ZZ

    def score(run: list[Seat]):
        group = run[:count]
        row_seats = rows_by_label[group[0].row_label]
        center = max(s.seat_number for s in row_seats) / 2
        avg_pos = sum(s.seat_number for s in group) / count
        return (row_rank(group[0].row_label) * 100) + abs(center - avg_pos)

    best_run = min(candidates, key=score)
    return best_run[:count]

2. Dynamic Pricing Engine

For high demand performances, prices can adjust based on venue fill rate and time to showtime. Once a user acquires a hold, the quoted price is persisted with the hold so later demand changes cannot alter the amount already offered to that user. Pricing policy is related to the Surge Pricing System problem.

PYTHON
from decimal import Decimal

def calculate_dynamic_price(show_id: str, section: str, base_price: Decimal) -> Decimal:
    show = get_show(show_id)
    section_info = get_section_info(show_id, section)

    # Factor 1: demand from fill rate
    fill_rate = Decimal("1") - (Decimal(section_info.available) / Decimal(section_info.total))
    demand_multiplier = Decimal("1.0")
    if fill_rate > Decimal("0.9"):
        demand_multiplier = Decimal("1.5")
    elif fill_rate > Decimal("0.7"):
        demand_multiplier = Decimal("1.2")

    # Factor 2: time to show
    hours_to_show = Decimal(str((show.show_time - now()).total_seconds() / 3600))
    time_multiplier = Decimal("1.0")
    if hours_to_show < Decimal("2"):
        time_multiplier = Decimal("0.7")

    price = base_price * demand_multiplier * time_multiplier
    bounded = max(min(price, base_price * Decimal("2.0")), base_price * Decimal("0.6"))
    return bounded.quantize(Decimal("0.01"))

3. QR Ticket Offline Verification

A signed QR payload can be verified without a network connection. Authenticity is different from replay prevention. A venue with multiple fully disconnected scanners cannot guarantee global single use without some shared or later synchronized scan state. Each scanner should therefore make its local check atomic and synchronize scan records when connectivity returns.

PYTHON
# Ticket verification at venue gates
# The QR contains a signed JWT payload, not just a mutable URL.
def verify_ticket_qr(qr_data: str) -> VerificationResult:
    try:
        payload = jwt.decode(qr_data, QR_PUBLIC_KEY, algorithms=["RS256"])
    except jwt.ExpiredSignatureError:
        return VerificationResult(valid=False, reason="Ticket expired")
    except jwt.InvalidTokenError:
        return VerificationResult(valid=False, reason="Invalid ticket")

    with local_scan_transaction():
        if is_already_scanned(payload["ticket_id"]):
            return VerificationResult(valid=False, reason="Ticket already used")
        mark_scanned(payload["ticket_id"])

    return VerificationResult(
        valid=True,
        seat=payload["seat_id"],
        ticket_id=payload["ticket_id"],
    )

4. Priority Based Waitlist

Store the priority tier separately from arrival time so higher priority users remain ahead without losing FIFO ordering inside the same tier.

PYTHON
WAITLIST_PRIORITY_RANK = {
    "premium_subscriber": 3,
    "loyalty_gold": 2,
    "regular": 1,
}
WAITLIST_BUCKET = 10**12

# Redis ZSET uses the smallest score first.
def waitlist_score(user, created_at):
    priority = WAITLIST_PRIORITY_RANK.get(user.tier, 1)
    return (max(WAITLIST_PRIORITY_RANK.values()) - priority) * WAITLIST_BUCKET + created_at.timestamp()

5. Scalper Bot Prevention

To prevent ticket hoarding by automated scalping scripts during hot on-sales, use multiple independent defenses so that bypassing one check does not expose the inventory.

Defense MechanismHow It WorksEffectiveness
CAPTCHA (reCAPTCHA v3)Score based risk assessment before allowing entry into the virtual queue.High
Strict Rate LimitingEnforces max 4 tickets per authenticated user, payment instrument, or phone number.Medium
Browser and Device SignalsUses browser behavior, device signals, and challenge responses to identify likely automation.High
TLS FingerprintingUses transport level client signatures as one signal among several bot indicators.Medium
Verified Fan ProgramPre-registers accounts before the on-sale and can require phone or identity verification.Very High
Phone OTP verificationRequires an SMS or OTP check at checkout for higher risk accounts.High

Interview Walkthrough

  • 25-minute cut

    Skip arch50 and arch75 depth unless interviewing for a staff-level role.

    • Functional and non-functional requirements with on-sale spike framing (3 min)
    • Virtual waiting room for admission control (7 min)
    • Redis atomic seat holds with TTL (7 min)
    • Reserved vs general admission scope (4 min)
    • Hold to database reconciliation on checkout (4 min)
  • Open with the on-sale spike scenario where millions of users compete for finite seats, framing it as a high contention inventory problem from the start.
  • Propose a virtual waiting room that regulates traffic admission before requests reach the Booking Service, tuning the drain rate with Back-of-the-Envelope Estimation.
  • Clarify the scope between reserved seating with interactive venue maps and general admission with shared inventory counters. General admission on-sales use bounded inventory operations rather than granular per-seat locks.
  • Explain split brain recovery: if Redis claims a hold but PostgreSQL says the seat is still available, PostgreSQL wins as the authoritative source of truth. The application evicts the invalid Redis key and rejects the hold with HTTP 409 Conflict.
  • Use atomic Redis reservation patterns with TTLs as a fast admission gate, using mechanisms described in Redis Patterns for System Design. Final reservation state is committed in PostgreSQL.
  • Contrast pessimistic locking, optimistic concurrency control, and Redis atomic holds. Pure pessimistic locking exhausts database connection pools during stadium concert spikes.
  • Ensure payment confirmation is strictly idempotent using mechanisms detailed in Payment Gateway. A retry with the same hold token and idempotency key must not create a duplicate charge.
  • Cover digital QR ticket verification with signed payloads, atomic local scan state, and anti-replay handling. Call out that fully offline global single use across disconnected gates requires later reconciliation.
  • Highlight bot mitigation layers such as CAPTCHA challenges, client rate limits, and verified fan accounts as operational necessities rather than peripheral features.
  • Avoid the common anti-pattern of acquiring row locks in PostgreSQL for every browsing query. Reserve transactional locking for state changing inventory operations.

Engineering Trade-offs

Pessimistic vs Optimistic Locking vs Redis Atomic Holds

Interviewers probe heavily into concurrency control during popular on-sale events. Walk through pessimistic vs optimistic locking and justify the hybrid model when one million users compete simultaneously.

Pessimistic Locking (SELECT FOR UPDATE) offers strong transactional correctness directly within the relational database by preventing another transaction from modifying locked rows. However, it scales poorly under massive on-sale demand because hundreds of thousands of concurrent requests block waiting for the same row locks, rapidly exhausting database connection pools and degrading overall throughput.

Optimistic Locking (CAS / Version check) delivers high throughput under moderate read heavy traffic because transactions validate a version only when writing. Under severe contention, however, many requests can fail and retry on the same seats. This creates a retry storm that wastes compute resources and degrades client response times.

Redis Atomic Holds (SET NX EX) provide sub millisecond reservation gating and expire abandoned reservations through TTLs without placing every initial conflict on PostgreSQL. The trade-off is dual-state complexity. The system must reconcile Redis and PostgreSQL while keeping PostgreSQL authoritative.

Architectural Recommendation: Adopt a hybrid locking model. High throughput Redis atomic operations absorb the initial reservation burst, while authoritative PostgreSQL transactions enforce durability and finalize booking state after payment capture succeeds.

Virtual Queue Drain Rate

Tuning the virtual waiting room drain rate is an operational balancing act. A high drain rate gives users a shorter wait but risks overloading the Booking Service and database connection pools. A conservative drain rate keeps the system stable but increases wait times and abandonment.

Back-of-the-Envelope Admission Rate Estimation:

To determine the ideal queue admission rate, apply this capacity planning formula:

YAML
Formula:
  Admit Rate (users/sec) = Available Seats / Avg Checkout Duration (seconds)

Example:
  - Event capacity: 5,000 available seats
  - Average checkout transaction duration: 3 minutes (180 seconds)
  - Target hold safety factor: 1.0 (fill all seats in one checkout wave)

Calculation:
  - 5,000 seats / 180 seconds = ~27.7 users/second

Operational Setting:
  - Set queue gate release rate to 28 users per second.
  - This bounds the number of simultaneously active seat holds and keeps PostgreSQL connection demand within the planned capacity.

💬Review

Help Us Improve

How helpful was this walkthrough?

Click a star to rate. We actively use this feedback to refine and update our system design content.

Placeholder
Optional but highly appreciated!

Discussion

Share your thoughts, ask questions, or help others.

Loading comments...