← System Design
Large-Scale Products

Design a Ticket Booking System (BookMyShow)

Watch: "System Design Interview: Design Ticketmaster w/ a Ex-Meta Staff Engineer" — Hello Interview
In simple words

Picking seats is like putting a "reserved" card on a cinema seat for 10 minutes while you go and pay. The rule that makes it safe: you can only put your card on a seat that has no card, and only one person can place a card at a time.

Problem Statement

Design a ticket booking system like BookMyShow or Ticketmaster: users browse shows, pick specific seats on a seat map, hold them for a few minutes while paying, and get a confirmed booking. The defining constraint is that a seat must never be sold twice, even when thousands of people try to book the same popular show in the same second.

Functional Requirements

Non-Functional Requirements

Core Entities

API Design

GET  /v1/shows?city=blr&movie=123&date=2026-10-02    -> shows (cached)
GET  /v1/shows/{show_id}/seats                        -> seat map with status

POST /v1/shows/{show_id}/holds  { seat_ids: ["F7","F8"] }
  -> 201 { hold_id, booking_id, expires_at }   |  409 { unavailable: ["F8"] }

POST /v1/bookings/{booking_id}/pay  { payment_method }   Idempotency-Key: ...
  -> 202 (redirect to payment gateway)
payment webhook -> confirms booking
GET  /v1/bookings/{booking_id}  -> { status, seats, ticket_qr }

High-Level Design (HLD)

Browsing is cached, holding a seat is transactionalUserWaiting room(hot sales)Catalog serviceBooking serviceCacheSeat inventory(by show_id)Payment serviceExpiry workerholdseatsrelease
A seat moves AVAILABLE → HELD → BOOKED only through conditional updates, so it can never be sold twice.
client -> CDN / API gateway
   |-- browse: Catalog service -> cache (Redis) -> catalog DB (read replicas)
   |-- seat map: Inventory service -> seat status (DB + short-lived cache)
   |-- hold/book: Booking service --(transaction)--> Inventory DB (show_seats)
                        |                              (sharded by show_id)
                        +--> Payment service --> payment gateway
                        |         webhook --> confirm booking
                        +--> expiry worker: releases holds past expires_at
                        +--> Notification (ticket email/SMS)

Hot sale: Virtual waiting room in front of the booking flow (admits users at a
controlled rate; everyone else waits in a queue page)

Low-Level Design (LLD)

Seat state machineAVAILABLEHELD(booking_id,expires in 10 min)BOOKEDhold (only ifAVAILABLE)paymentconfirmedhold expires / payment fails
Every arrow is one conditional UPDATE; the WHERE clause on the current status is what prevents double booking.

Holding seats atomically

show_seats(show_id, seat_id, status, hold_expires_at, booking_id, version,
           PRIMARY KEY (show_id, seat_id))

-- Hold: one conditional UPDATE for all requested seats, inside a transaction
BEGIN;
UPDATE show_seats
   SET status = 'HELD', booking_id = :b, hold_expires_at = now() + interval '10 min'
 WHERE show_id = :show AND seat_id IN ('F7','F8')
   AND (status = 'AVAILABLE' OR (status = 'HELD' AND hold_expires_at < now()));
-- rows_affected must equal 2; otherwise ROLLBACK and return 409
COMMIT;

The WHERE status = 'AVAILABLE' condition is the whole trick: the database's row locks make two concurrent holds on F8 serialize, and the second one updates zero rows. No application-level lock is needed. An alternative is a Redis SET seat:{show}:{seat} NX PX 600000 per seat for the hold, with the database as the final record; faster under extreme contention but two systems to keep consistent.

Confirm and expire

payment success webhook (idempotent by provider reference):
  UPDATE show_seats SET status='BOOKED'
   WHERE booking_id=:b AND status='HELD' AND hold_expires_at > now();
  if all seats updated -> booking CONFIRMED -> send ticket
  else (hold expired while paying) -> refund automatically, apologise

expiry worker every few seconds:
  UPDATE show_seats SET status='AVAILABLE', booking_id=NULL
   WHERE status='HELD' AND hold_expires_at < now();

Flash-crowd handling

Follow-up Questions

Two users click the same seat at the same millisecond. Walk through it.
Both send a hold; both transactions try to update the row with status='AVAILABLE'. The database locks the row for the first; the second waits, then re-evaluates the condition, finds HELD, updates 0 rows and returns 409. The UI asks the second user to pick another seat.
Payment succeeded but the webhook arrives after the hold expired and the seat was resold.
The confirm update affects 0 rows, so the booking is marked failed and an automatic refund is issued. To make this rare: extend holds while a payment is in progress, and use holds slightly longer than the payment timeout.
Why not optimistic locking with a version column?
That works too (update where version = read_version). Under heavy contention on the same seats, the conditional update on status is simpler and does the same job in one statement.

Related