Design movie ticket booking (BookMyShow)
The interviewer asks
“Design a movie and event ticket booking platform like BookMyShow. Users pick a city, a movie, a show and specific seats on a seat map, pay, and get a ticket. Two people must never get the same seat, and the system must survive the rush when bookings open for a huge release or a concert.”
What a strong answer looks like
A strong answer separates the browse path (movies, cinemas, showtimes: read-heavy, cacheable) from the seat path (seat map, hold, pay, confirm: contended and must be correct). Seats are held for a few minutes while the user pays; a hold is an atomic conditional write on the seat rows for that show (or a Redis SET NX per seat with TTL backed by the database), and confirmation converts holds to bookings only after payment succeeds. Many cinemas run their own ticketing software, so the platform often must lock seats in the cinema's system through an integration, which adds latency and failure modes worth naming. For blockbuster openings and concerts, a virtual waiting room admits users at a controlled rate so the seat service sees steady load. Deep dives: the seat hold model and its expiry, payment and the confirm race, the waiting room, and keeping seat maps fresh without hammering the database.
Concepts to mention
1 Requirements
Functional
- Browse movies and events by city; see cinemas and showtimes with prices.
- Open a seat map for a show with live availability; choose up to 10 seats.
- Hold the chosen seats for ~8 minutes while the user pays; release them if payment does not complete.
- Pay (UPI, cards, wallets, offers) and receive an e-ticket with a QR code.
- Cancel per cinema policy; refunds to source.
- Cinemas and event organisers configure screens, seat layouts, shows and pricing; some cinemas sync from their own ticketing system.
Non-functional
- Zero double-booked seats.
- Seat map load p95 under 300 ms; hold API p95 under 200 ms (direct inventory).
- Handle opening-day rush: ~1M users trying to book in the first minutes of a blockbuster or concert sale.
- Availability 99.95% for booking; browse can serve slightly stale data.
- Fairness during rushes: first-come-first-served admission, bots limited.
- Payment never charged without a ticket or an automatic refund.
Out of scope (say it out loud)
- Recommendations and reviews.
- Food and beverage ordering (mention as add-on).
- Cinema-side box office operations.
2 Estimates
3 API
| Endpoint | What it does |
|---|---|
GET/v1/cities/{city}/movies | Now showing; CDN cached. |
GET/v1/movies/{id}/shows?city=&date= | Cinemas and showtimes with price ranges and availability indicator. |
GET/v1/shows/{id}/seats | Seat layout and status (available, held, booked); layout cached, status short-TTL. |
POST/v1/shows/{id}/holds | Hold {seat_ids}; Idempotency-Key; returns hold_id and expires_at. |
DELETE/v1/holds/{id} | Release a hold early (user goes back). |
POST/v1/bookings | Create booking from hold_id with payment method; returns payment intent. |
POST/v1/webhooks/payments | Payment result; confirms booking or triggers refund. |
GET/v1/queue/{event_id}/status | Waiting room position and admission token. |
4 Data model
showsPostgresshow_idscreen_idmovie_idstart_timeprice_categoriesinventory_source (direct|cinema_api)Catalogue data; replicated to read replicas and cached.
seat_inventoryPostgres sharded by show_id(show_id, seat_id) PKcategorystatus (AVAILABLE|HELD|BOOKED)hold_idhold_expires_atbooking_idversionAll seats of a show on one shard, so a multi-seat hold is one local transaction.
holdsPostgres + Redis TTL keyhold_idshow_iduser_idseat_idsexpires_atstatusRedis key hold:{id} with TTL drives fast expiry; DB row is the truth for reconciliation.
bookingsPostgresbooking_iduser_idshow_idseat_idsamountpayment_idstatus (PENDING|CONFIRMED|REFUNDED)qr_codeidempotency_keyPer-user listing via index on user_id or a user-sharded read model.
seat map cacheRedisseats:{show_id} → bitmap or compact status arrayversionRebuilt on every hold/booking change for that show; served to all viewers.
waiting roomRedisqueue:{event_id} sorted set (user, arrival)admitted countersigned tokensOnly enabled for high-demand events.
5 Architecture
Hover a box to highlight its connections.
6 Request flows
Hold seats
- 1User picks seats C7 and C8; the app calls POST /shows/{id}/holds with an Idempotency-Key.
- 2Seat service runs one transaction on the show's shard: UPDATE seat_inventory SET status='HELD', hold_id=?, hold_expires_at=now()+8min WHERE show_id=? AND seat_id IN (C7,C8) AND status='AVAILABLE'.
- 3If the affected row count is 2, it inserts the hold row and commits; otherwise it rolls back and returns 'seats just taken' with the fresh seat map.
- 4For cinemas whose inventory lives in their own system, it calls the cinema's lock API first and stores the external lock reference in the hold.
- 5It bumps the seat map version in Redis and returns hold_id with expires_at; the app shows a countdown.
Pay and confirm
- 1User chooses UPI; the booking service creates a PENDING booking linked to hold_id and a payment intent.
- 2The payment webhook arrives (deduped by payment_id); the service confirms in one transaction: seats HELD by this hold_id → BOOKED, booking → CONFIRMED.
- 3If the hold already expired and the seats were released, confirmation fails; the service retries holding the same seats, and if they are gone, refunds automatically.
- 4BookingConfirmed goes out via the outbox; workers generate the QR ticket, commit the sale in the cinema system and send SMS/WhatsApp/email.
Opening-day rush
- 1For a flagged event, all seat and booking traffic passes through the waiting room; users get a random-order position if they arrived before the sale opened, FIFO after.
- 2The waiting room admits users at a rate the seat service can handle (e.g. 2,000/min) and gives each a signed token valid for ~15 minutes.
- 3The gateway rejects seat API calls without a valid token; browse pages stay on the CDN.
7 Deep dives
Seat holds: atomic, short-lived and self-healing
The core invariant is that a seat is BOOKED by at most one booking. Make a hold an atomic conditional write over all requested seats: a single UPDATE with WHERE status='AVAILABLE' on rows of one show (one shard), and compare the affected row count with the number of seats. Row locks inside the database serialise two users racing for C7; one wins, the other sees zero rows and gets a fresh map. Hold for all-or-nothing so a family of four does not end up with three seats. Holds expire after ~8 minutes. Use two mechanisms: a Redis key with TTL or a delay queue that triggers release at expiry, and a sweeper that releases any row with hold_expires_at < now() as a safety net. Release must be conditional too (WHERE hold_id=? AND status='HELD'), so a late release never frees a seat that was just booked. Some teams use Redis SET NX per seat as the lock; that is fast but you then have two sources of truth. If you use it, the database write is still the commit point. A per-show actor or single-writer queue is an option for stadium events with very high contention on one show.
Payment and the confirm race
Payment is the slowest and least reliable step: UPI approval can take a minute, and webhooks can arrive after the hold expired. Design for three outcomes. Normal: the webhook arrives in time, and a single transaction converts HELD to BOOKED where hold_id matches and marks the booking CONFIRMED. Late: the hold expired and seats were released. Try to re-hold the same seats; if someone else took them, refund automatically and tell the user clearly. Unknown: no webhook arrives. A reconciliation job polls the gateway for PENDING bookings after a few minutes and settles them the same way. Extend the hold once payment has started (for example to 15 minutes) so a slow UPI approval does not lose seats. Use an idempotency key on booking creation and dedupe webhooks by payment_id. Never confirm a booking before the gateway says the money is captured, and keep the refund path automatic because manual refunds generate support tickets and social media complaints within minutes.
Virtual waiting room for blockbuster and concert sales
When a big concert or a superstar's film opens bookings, a million people arrive in minutes, many refreshing repeatedly. Scaling the seat service to absorb that is wasteful and still unfair. Put a waiting room in front: users who arrive before the sale opens are given random positions (so being early by a second gives no advantage over being early by a minute), and later arrivals queue FIFO. The queue lives in Redis (sorted set by position) or a managed service; the page polls its position every ~10-20 seconds from a CDN-cacheable endpoint. An admitter releases users at a fixed rate tuned to what the seat service handles comfortably, issuing a signed token (JWT with event_id, user_id, expiry). The gateway checks the token on every seat and booking call, so bots cannot skip the queue. Limit tickets per account and per payment instrument, require login and OTP-verified phone numbers, and show the expected wait. When seats sell out, tell everyone still in the queue immediately instead of letting them wait.
Seat maps that are fresh without hammering the database
During a rush thousands of users watch the same show's seat map. Reading 300 rows from Postgres per view would overload the shard. Keep a compact seat status array per show in Redis (one byte or two bits per seat) with a version number. Every hold, release or booking for that show updates it after the database commit; a view is a single Redis GET of a few hundred bytes. The layout (rows, gaps, categories) is static and cached on the CDN, so only status travels. The map can be a second stale; that is fine because the hold API is the real check and returns the fresh map on conflict. For live updates, clients can poll with the version (304 if unchanged) or subscribe via SSE for very hot shows. For inventory owned by a cinema's own system, cache their availability for a few seconds and accept more conflicts at hold time.
8 Trade-offs
Database conditional update vs Redis seat locks
Database: one source of truth, transactional across seats, simpler recovery; enough for a few thousand holds/s across sharded shows. Redis SET NX per seat: faster under extreme contention, but needs a second write to the database and careful handling when they disagree. Start with the database; add Redis locks only for stadium-scale events.
Hold before payment vs pay first
Holding first gives the user certainty about seats and avoids refunds, at the cost of seats being locked by people who abandon. Paying first wastes fewer seats but causes many refunds when the seats are gone. Hold with a short TTL, and extend once payment starts.
Own inventory vs cinema system as source of truth
Where the cinema runs its own ticketing system, it is the source of truth because box office also sells seats. The platform must call its lock and commit APIs, which are slower and less reliable. Where cinemas give the platform an allocation, inventory is direct and fast. Support both behind one inventory interface.
9 Bottlenecks
One very hot show
A single stadium show with 50k seats concentrates all writes on one shard. Partition the show's inventory by section (each section is its own row group or actor), have the UI choose a section first, and admit users through the waiting room at a rate the shard can handle.
Payment gateway capacity during rush
Thousands of payments per minute can hit gateway rate limits. Use multiple gateways with routing by success rate, and keep holds alive during payment so gateway slowness does not lose seats.
10 Failure handling
Sweeper or expiry trigger is down
Expired holds keep seats unavailable longer than needed, but no double booking happens. The hold API also treats a seat whose hold_expires_at has passed as available (WHERE status='AVAILABLE' OR hold_expires_at < now()), so correctness and availability recover even without the sweeper.
Cinema API timeout during hold
Treat the external lock as unknown: retry with the same request ID, and if still unknown, release on our side and tell the user to retry. A background reconciler fetches the cinema's state and releases orphaned locks. Circuit-break per cinema chain so one slow chain does not exhaust threads.
Payment captured, booking not confirmed
The reconciliation job finds payments without confirmed bookings and either confirms (if seats are still held) or refunds. Alert when this rate rises, because it usually means webhook delivery is broken.
11 What impresses
- Writes the conditional UPDATE and checks affected rows, making holds all-or-nothing.
- Covers all three payment outcomes, including success after hold expiry.
- Treats expired holds as available in the hold query so correctness does not depend on a sweeper.
- Brings in a waiting room with random ordering for pre-sale arrivals and token checks at the gateway.
- Knows many cinemas own inventory in their own system and designs for that integration.
- Splits static layout from dynamic status in the seat map cache.
12 Mistakes
- Read-then-write in application code without locks or conditions.
- Confirming the booking before payment is captured.
- Distributed lock per seat with no thought about lock expiry and database disagreement.
- Scaling servers for the rush instead of controlling admission.
- Ignoring partial holds when some seats in a group are taken.
- No automatic refund path.
13 Follow-ups
Lessons this design uses
Practice questions
Saved on this device. Create a free account to keep it on every device.
Practise this with Sam
A real mock interview, out loud, with an honest scorecard. Free to start.