From "50,000 fans clicking the same seat at 09:00:00.001" to a sharded, ACID-backed, fairness-queued booking system — the architecture that earns every box
This deep-dive applies the 4-step HLD interview framework. As you read, map each section to Requirements → Entities → APIs → High-Level Design → Deep Dives, and notice which of the 8 common patterns and key technologies are at play.
It's 08:59 on a Friday morning. The new Marvel film opens at midnight. Sarah, sitting in Bangalore, has her finger hovering over the "buy" button on BookMyShow. So does Raj, two cubicles down. So do 50,000 other fans across the country, all targeting the same theater — PVR Forum, Screen 1 — which has exactly 200 seats. At 09:00:00 sharp the booking window opens. Within the next 0.4 seconds, every one of those 50,000 fans hits "select seat" — and many of them tap the same seat (the legendary J-12, dead center).
The system has to do four things, all at once, all correctly: browse (show movies, theaters, showtimes), select seats (let users pick from a live seat-map), pay (collect money via Stripe/Razorpay), and issue tickets (email a confirmation with a QR code). And it must do all of this with zero double-bookings — because if two people show up at PVR Forum holding tickets for J-12, the company gets sued and the brand dies. That's the system we're designing.
Before drawing a single box, pin down what the system must do. In an interview, asking these questions out loud signals you're building from first principles, not pattern-matching a memorized solution.
Four hard constraints shape this whole system. Skip any one of them and the design falls apart in production.
If Sarah wants 4 seats together for her family, the system must book all 4 or none. Booking 3 of 4 and then failing on the fourth leaves her with three useless tickets and a missing family member. This is a textbook ACID transaction — the kind a relational DB does effortlessly and a NoSQL store fights you on.
When the show is full and someone abandons their hold, the freed seat shouldn't go to whoever happens to refresh first — it should go to whoever has been waiting longest. Without explicit fairness, the experience devolves into a refresh-button arms race that rewards bots and punishes patient users.
Cap orders at 10 seats. Higher caps invite scalpers who buy 200 seats and resell them at 5× face value. The cap, combined with rate-limiting by user/payment-card, makes large-scale scalping operationally painful.
Average traffic might be 1K req/s; release-day traffic for a Marvel/Avengers premiere can be 50K req/s for the first 60 seconds. The system must scale horizontally on demand, and the booking path must not melt under burst load.
Numbers drive every architectural choice. Out loud, even if rough. The system is read-heavy on browse (millions of people checking showtimes) but write-coordinated on book (a smaller number of high-stakes transactions).
Assume 3 billion page views per month across browse paths (city → movie → cinema → show → seat-map). Of those, roughly 10 million tickets sold per month — about a 300:1 browse-to-book ratio.
~1.2K req/s avg
3B / (30 × 86400)
~4 req/s avg
10M / (30 × 86400)
~50K req/s
40× spike on release
~200 req/s
seat-contention burst
Per day across the catalog: 500 cities × 10 cinemas/city × 2,000 seats/cinema × 2 shows/day × 100 bytes/seat-row ≈ 2 GB/day. Over 5 years, including bookings, payments, and audit trails: ~3.6 TB total. With 70% headroom: ~5 TB provisioned.
| Metric | Value | Why it matters |
|---|---|---|
| Browse req/s (peak) | 50K/s | Drives cache size and read-replica fan-out |
| Booking req/s (peak) | 200/s | Drives DB write tier and isolation strategy |
| 5-yr storage | 3.6 TB | Fits a single MySQL cluster with read replicas |
| Tickets sold / month | 10M | Drives notification, payment, audit volume |
| Seats per booking | ≤ 10 | Anti-scalping cap; cap shapes lock granularity |
Two endpoints carry the interesting load: search (find what you want to watch) and reserve (claim seats while paying). A third endpoint completes the booking after payment success. Defining the contract early locks down the architecture before the first box is drawn.
REST API surface// Search — read path, high QPS GET /api/v1/search { "api_key": "abc123...", "keyword": "Marvel", // optional movie name fragment "city": "Bangalore", // filter by city "lat_long": "12.97,77.59", // optional, for nearby cinemas "radius_km": 10, // search radius from lat_long "datetime": "2026-05-08T18:00", "postal_code": "560001", // alternative to lat_long "sort": "showtime" // showtime | rating | distance } → 200 OK { "results": [{ movie, cinema, show_id, showtime, available_seats }, ...] } // Reserve — write path, the contention hot spot POST /api/v1/reserve { "api_key": "abc123...", "session_id": "sess-9d4f...", // sticky session for checkout "movie_id": "mov-1234", "show_id": "show-7891", "seats": ["J-12", "J-13"] // 1..10 seats } → 200 OK { "reservation_id": "res-...", "expires_at": "...+5min", "amount": 480 } → 409 Conflict { "error": "seats_taken", "alternatives": ["J-14","J-15"] } → 429 Queued { "error": "show_full", "queue_position": 47, "etr_seconds": 180 } // Confirm — called after payment succeeds POST /api/v1/confirm { "reservation_id": "res-...", "payment_token": "tok_..." } → 201 Created { "booking_id": "bk-...", "tickets": [{seat, qr_code}, ...] }
The data model is wide — about 10 entities — but every interesting interaction touches the same handful: Show, Show_Seat, Booking, Payment. Three observations: (1) we have strong relational ties (a Show belongs to a Cinema_Hall, a Show_Seat belongs to a Show and a Cinema_Seat), (2) we need multi-row atomic transactions for multi-seat orders, and (3) the UNIQUE constraint on a Show_Seat row is what physically prevents double-booking. All three points push us toward a relational store like MySQL/PostgreSQL.
The SHOW_SEAT row is the heart of the system. Its status column is a small state machine — FREE, HELD, or BOOKED — and a UNIQUE constraint on (show_id, cinema_seat_id) means the database itself rejects any attempt to create two rows for the same seat in the same show. Combined with row-level locking, this is what physically prevents double-booking even under massive contention.
show_seat or fail" — that's a textbook multi-row transaction with strong isolation. NoSQL stores either give you per-row atomicity (not enough) or eventual consistency (catastrophic for ticket booking). MySQL or Postgres with SERIALIZABLE isolation handles this in 5-line SQL. Our 3.6TB also fits comfortably in a sharded MySQL cluster, so we're not paying for NoSQL's horizontal-scale superpower.This is the section that wins or loses the interview. We'll build the architecture in three passes: the simplest thing that could plausibly work, why it falls apart at scale, and the production shape where every box justifies itself.
Sketch the simplest possible system: a few stateless app servers behind a load balancer talking to one MySQL. To book a seat, the app does INSERT INTO booking .... Done.
Three failures emerge the moment a real movie release hits this design:
Sarah and Raj both tap seat J-12 at 09:00:00.001. App server A handles Sarah, app server B handles Raj. Both run INSERT INTO booking at the same instant. Without row-level locking and a UNIQUE constraint, both inserts succeed — and now two physical humans hold a ticket for the same chair. The brand is dead.
A user clicks "select seats" then walks away to make tea. If the seat sits in HELD state for 30 minutes with no automatic expiry, that's 30 minutes of every other fan seeing it as taken. With 200 seats and 50K interested fans, the show looks "sold out" within seconds even though most "holds" never become bookings.
A single MySQL handles ~5K simple writes/sec. A flash-sale spike of 50K req/s on the booking path pegs the DB CPU at 100%, p99 latency goes from 5ms to 5 seconds, and connection pools start dropping requests. The whole site stops responding — not just the popular movie.
Here's the central insight that reshapes the whole design: a seat reservation is two things at once — (a) a persistent claim in the database (so we never double-book and so it survives a crash), and (b) a 5-minute timer living in memory (so we know when to expire it and let someone else have a turn). One alone isn't enough.
Think of it like a restaurant that takes a name on the waitlist and sets a 30-minute timer on the host stand. The name on paper is durability — even if the host gets fired, the next host can pick up. The timer is liveness — without it, no-shows squat on tables forever. Booking systems need both. So we split the work across two cooperating services:
~50K req/s peak. Read-only fan-out — list cities, movies, cinemas, shows, seat-maps. Tolerates stale data (a 2-second-old seat-map is fine). Cacheable to the moon. No locks, no transactions.
~200 req/s peak. The contended write path. Transactional, ACID, row-locked. Must be fast (sub-100ms) to absorb burst contention. Holds the 5-minute timers for in-progress reservations.
Background. The fairness queue, expiry timers, payment callbacks, notifications. Wakes the longest-waiting user when a seat frees up. Decoupled so a stuck SMS provider can't slow checkout.
That three-plane split lets us scale browse on cheap stateless replicas, keep the booking path narrow and ACID-strict, and bury the messy "who gets the freed seat?" question inside an ActiveReservationsService (timers) and a WaitingUsersService (the deli-counter ticket queue) running in the async plane.
Now the full picture. Every node is numbered — find its matching card below to see what it does and crucially what would break without it.
Use the numbers in the diagram above to find the matching card. Each one answers what is this, why is it here, and what would break without it.
The browser tab or mobile app a user is staring at. It walks the user through the funnel: pick a city, pick a movie, pick a showtime, watch the seat-map render, tap seats, hit "pay", enter card details, see the QR code. From the client's view the entire system is one HTTPS endpoint — but every interesting concern (latency, fairness, idempotency) is shaped by what the client does next.
Solves: nothing on its own — but every architectural decision flows backward from "what does the user see and how does it feel?" Sticky sessions, idempotency keys, and reservation timers are all about not breaking the client's mental model of "I clicked a seat, it's mine."
The front door. Sits in front of all stateless services, distributes incoming HTTPS, terminates TLS, and yanks unhealthy backends out via 5-second health checks. Crucially we use sticky sessions during checkout — once a user has a reservation in flight, the LB pins them to the same Booking Service node so the in-memory session state (selected seats, payment intent) doesn't have to be re-fetched on every click.
Solves: single-point-of-failure on app servers, plus the "lost cart" problem if requests bounce between nodes mid-checkout. Without the LB, a single pod crash takes down the site. Without sticky sessions, a user who refreshes mid-checkout might land on a fresh node that has no idea they're holding 4 seats.
Stateless service that answers all the browse queries: "what cities?", "what's playing in Bangalore tonight?", "what are the showtimes for Marvel at PVR Forum?". It hits the cache first, falls through to the catalog DB on miss. Read-only, so it scales horizontally — add pods until the LB stops complaining.
Solves: isolating the read-heavy browse workload from the contended booking path. Without a dedicated search service, the same pods serving 50K browse req/s would also be running ACID seat-locks, and the locks would starve under read pressure.
The denormalized read-store for the browse plane. Holds movies, cinemas, halls, showtimes, and a snapshot of seat-availability counts per show (not the live row-level state). Cassandra is right here because the data is read-heavy, eventually-consistent, and replicates naturally across regions — a fan in Mumbai shouldn't pay a 200ms round-trip to a primary in Delhi just to see what's playing tonight.
Solves: the 50K-req/s browse spike. Without a denormalized read store, every "list movies in city" would join across movie, show, cinema, cinema_hall on the booking DB — exactly the joins the booking DB is too busy doing transactions to serve.
Stateless service that handles POST /reserve and POST /confirm. The reserve flow per request: validate input → start a SQL SERIALIZABLE transaction → SELECT ... FOR UPDATE on the requested show_seat rows → if all are FREE, mark them HELD with a 5-minute expiry, register the timer with ActiveReservationsService, COMMIT → return reservation ID. Total budget: under 100ms.
Solves: the contended write path. Without a dedicated booking service, locking semantics would be sprinkled across whatever node happens to handle the request, and you'd never get the discipline needed to prevent double-bookings under 50K req/s.
The source of truth for everything that costs money — bookings, payments, the show_seat rows whose status is HELD or BOOKED. MySQL with InnoDB row-locks and SERIALIZABLE isolation. The UNIQUE constraint on (show_id, cinema_seat_id) in the show_seat table is what physically guarantees no double-booking — even if every other line of code had a bug, the DB would refuse the second insert.
Solves: correctness under contention. Without a relational ACID store, you'd be re-implementing SERIALIZABLE in application code on top of an eventually-consistent NoSQL — a battle no team has ever won at this scale.
An in-memory map of {show_id → list of (reservation_id, expires_at)} for every reservation that's currently HELD but not yet paid. When a reservation is created, the booking service registers its 5-minute timer here. When the timer rings, this service atomically transitions the seat back to FREE in MySQL and notifies WaitingUsersService "a seat just opened on show X". Backed by the DB — on crash, recover by reading all HELD rows.
Solves: liveness. Without a dedicated timer service, expiring abandoned reservations means polling the DB for "rows where expires_at < now" — a giant scan that gets slower as the table grows. The in-memory timer wheel is O(1) per expiry.
Per-show FIFO queue of users waiting for seats to free up after a sellout. When the show is full and a fan tries to reserve, they're appended to the queue with a notification handle. When ActiveReservationsService announces "seat freed on show X", this service pops the head of the queue, sends them a "your seats are available — complete checkout in 5 min" push, and starts another 5-minute timer. If they don't claim, they expire out and we try the next person. This is the deli-counter analogy made literal.
Solves: fairness during sellouts. Without an explicit queue, freed seats go to whoever happens to refresh their browser at the right millisecond — which favors bots and frustrated power-refreshers, not the patient fan who's been waiting 20 minutes.
Wraps Stripe / Razorpay / native UPI integrations. Booking Service calls it with the reservation ID, the amount, and a payment token from the client. Returns success/failure asynchronously via webhook. Holds an idempotency key so retries don't double-charge a card. On success it calls back into Booking Service to flip the reservation from HELD to BOOKED.
Solves: isolating the slow, flaky external dependency from the fast in-process reservation logic. Without a separate service, Stripe's 3-second p99 latency would balloon every reservation request to 3 seconds — and Stripe's occasional outages would directly take down booking.
Async fan-out to email, SMS, and push. Sends the "you reserved 4 seats — pay in the next 5 minutes" warning, the "your seats are available" wake-up to waiting users, and the final "here's your QR code" confirmation. Subscribes to events from Booking Service via Kafka — never inline on the request path, so a flaky SMS provider can't slow checkout.
Solves: communication without coupling. Without an async notification service, every booking would block on "send SMS" — and SMS gateways have multi-second tail latencies.
An in-memory key-value store holding (a) show metadata that almost never changes (movie title, cinema name, showtime), and (b) seat-map snapshots — a compact bitmap per show showing which seats are FREE/HELD/BOOKED, refreshed every 1-2 seconds. Read flow: search service hits Redis first, falls through to Cassandra on miss.
Solves: the 50K browse req/s spike. Without a cache, the seat-map render alone (every fan opens 5 seat-maps before settling on one) would push the catalog DB past its read ceiling. With it, 95%+ of seat-map renders never reach the DB.
Two real flows, mapped to the numbered components above. The first shows concurrency resolution; the second shows expiration and fairness.
/reserve with seat J-12. Both hit the Load Balancer ②, which pins each user to a Booking Service ⑤ pod (sticky session).SERIALIZABLE isolation: BEGIN; SELECT * FROM show_seat WHERE show_id=X AND seat='J-12' FOR UPDATE;FREE, updates to HELD, registers the 5-minute timer with ActiveReservationsService ⑦, and COMMITs. Lock released.HELD, ROLLBACKs and returns 409 Conflict — alternatives: J-11, J-13, J-14.UPDATE show_seat SET status='FREE', booking_id=NULL WHERE reservation_id=... — atomic transition back to FREE.This is the section interviewers probe hardest. Get the isolation level wrong and you ship double-bookings; get it too strict and the site goes unresponsive on release morning. The right answer for ticket booking is SERIALIZABLE isolation with row-level SELECT ... FOR UPDATE — and the trade-offs need to be on the tip of your tongue.
| Level | Dirty read | Non-repeatable read | Phantom read | Throughput |
|---|---|---|---|---|
| READ UNCOMMITTED | ❌ allowed | ❌ allowed | ❌ allowed | highest |
| READ COMMITTED | ✅ blocked | ❌ allowed | ❌ allowed | high |
| REPEATABLE READ | ✅ blocked | ✅ blocked | ❌ allowed | medium |
| SERIALIZABLE | ✅ blocked | ✅ blocked | ✅ blocked | lowest |
For booking, we need all three anomalies blocked. A "phantom read" — the seat appears FREE in our SELECT but a concurrent transaction inserted a competing booking before our INSERT lands — is the literal definition of a double-booking. So we go to the top: SERIALIZABLE.
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE; BEGIN; SELECT seat_id, status FROM show_seat WHERE show_id = 'show-7891' AND seat IN ('J-12','J-13') FOR UPDATE; -- row-level X-lock -- guard: bail if any seat is not FREE UPDATE show_seat SET status='HELD', booking_id='res-...', expires_at=NOW()+300 WHERE show_id = 'show-7891' AND seat IN ('J-12','J-13') AND status = 'FREE'; COMMIT;
Three things make this watertight: (a) SERIALIZABLE blocks phantom inserts on the same seat-row, (b) FOR UPDATE takes an exclusive row-lock so the second transaction physically waits, and (c) the AND status = 'FREE' guard in the UPDATE means even if you got the order wrong, the UPDATE updates 0 rows and we know to bail.
A single seat moves through a small, strict state machine. Every transition is owned by exactly one component, and every transition is atomic in the DB.
Owned by Booking Service. Triggered by POST /reserve. Must be an atomic SELECT ... FOR UPDATE + UPDATE at SERIALIZABLE isolation. On success, also registers a 5-minute timer in ActiveReservationsService.
Owned by Booking Service, triggered by a payment-success webhook from Payment Service. Single SQL UPDATE flipping status and clearing expires_at. The reservation timer in ActiveReservationsService is cancelled.
Owned by ActiveReservationsService. Triggered by the in-memory 5-minute timer firing. The service runs UPDATE show_seat SET status='AVAILABLE', booking_id=NULL WHERE reservation_id=..., then publishes "seat freed" to WaitingUsersService.
User clicks "give up these seats" before the timer fires. Same UPDATE as the expiry path, just triggered by a user action. The timer is cancelled in ActiveReservationsService to prevent a duplicate UPDATE.
WHERE status='HELD', so the second one updates 0 rows and silently succeeds.When a show goes "full", the next 100 fans who try to reserve don't see "sorry, sold out" — they see "you're number 47 in the queue, estimated wait 3 minutes". The queue exists because, on average, 5-10% of HELD reservations expire without payment — meaning even sold-out shows have a steady drip of seats coming back, and someone has to be next in line.
One queue per show, keyed by show_id. Each entry: {user_id, seats_wanted, enqueued_at, notification_handle}. Stored in memory only — losing the queue on a crash is acceptable because waiters re-poll the seat-map and re-queue if needed.
When a waiter is woken, they get a 5-minute window to actually click "reserve". If they don't, they expire out (e.g., they fell asleep) and we wake the next in queue. This prevents the queue from stalling on no-shows.
The queue lives on the same node as that show's ActiveReservationsService shard. Both are sharded by show_id so all events for one show — expiries, dequeues, notifications — happen on one machine. No cross-node coordination, no race conditions.
If a queued user wants 4 seats but only 2 freed up, we don't dequeue them — we keep them in queue and wake the next user wanting 2 or fewer. Optional: a separate "split" queue for users who'd accept fewer seats, but this is a UX choice with trade-offs.
Two stateful services run in memory — ActiveReservationsService and WaitingUsersService. What happens when one of those nodes crashes mid-show?
The in-memory timers are derived state — the source of truth lives in the show_seat table as rows where status='HELD' AND expires_at IS NOT NULL. On startup, the service reads all such rows and rebuilds the timer wheel. Held reservations whose timers fired during the crash are immediately expired on recovery.
Failure mode: a 30-second crash means held reservations expire 30 seconds late — annoying for waiters but never wrong. No double-bookings, no lost money.
The waiting queue is in-memory only with no DB backing. A crash loses every waiter's queue position. We accept this because (a) the cost of persisting every queue mutation is high, (b) the consequence is "fans see a 'queue position lost, please re-queue' banner", and (c) it's reasonable to ask a fan who's been waiting 10 minutes to refresh.
Mitigation: run a primary/standby pair with hot-failover, so an actual crash is rare. Even if the standby's queue is empty, the failover takes seconds and re-queueing is fast.
The Bookings DB is a primary with two synchronous replicas in different availability zones. Writes commit only after at least one replica acknowledges. Automated failover (e.g., via Orchestrator or Aurora's built-in failover) promotes a replica in 10-30 seconds on primary failure.
RPO=0, RTO≈30s — no data loss, brief unavailability.
Every /confirm call carries an idempotency key. If the network drops mid-call and the client retries, the Payment Service recognizes the same key and returns the already-charged response — never double-charging the card. The Booking Service does the same when receiving the webhook callback.
3.6TB fits on one big MySQL box, but a single box can't survive its own failure and can't absorb a release-day spike on its own. We shard the Bookings DB. Choosing the shard key is the most consequential decision in this section.
movie_idTempting because a query like "show me all bookings for Marvel" stays on one shard. But: when Avengers releases, every booking in the country flows to one shard. That shard's CPU pegs at 100% while the others sit at 5%. Hot-shard hell.
show_id (with consistent hashing)A specific show is "Avengers · 21:00 · PVR Forum". There are millions of shows across the catalog and any given show has at most ~500 seats — bounded write volume. Hashing show_id distributes load uniformly even on release day.
show_id is the right key — three reinforcing reasonsThe hottest possible thing is a single sold-out show — say 500 seats × 50 attempts/seat = 25K writes total. That's ~5 minutes of work for a single shard at 200 req/s. Nothing melts.
ActiveReservationsService and WaitingUsersService are also sharded by show_id — same key, same node. All operations for one show land on one server set, so timer expiry and queue dequeue happen without cross-node coordination.
Use consistent hashing not hash % N. Adding a shard relocates only 1/N of shows instead of all of them — a few hours of rebalance instead of a multi-day migration with the cluster degraded throughout.
Two infrastructure pieces with disproportionate impact on whether release day feels fast or feels broken.
Movie title, cinema name, hall name, showtime, total seats, base price. Effectively immutable for the life of the show. TTL: hours. Hit rate: 99%+. Lives in Redis as JSON blobs keyed by show:<id>.
A compact bitmap per show showing FREE/HELD/BOOKED for each seat. Refreshed every 1-2 seconds from the bookings DB. Slightly stale is fine — when a fan clicks a stale-FREE seat, the SERIALIZABLE transaction catches the conflict and returns 409. The cache absorbs 50K req/s of seat-map renders.
The active state of a seat (am I currently HELD by Sarah?) lives only in the bookings DB and ActiveReservationsService. Caching it would invite stale-read double-bookings — not worth it.
The LB does normal round-robin for browse traffic. But the moment a user starts /reserve, we set a sticky cookie pinning that user to the same Booking Service pod for the rest of their checkout (up to 5 minutes). This isn't strictly required for correctness — the DB is the source of truth — but it lets the Booking Service keep small in-memory state (selected seats, payment intent, idempotency key) instead of refetching on every click.
SERIALIZABLE isolation level — blocks dirty, non-repeatable, and phantom reads. (2) SELECT ... FOR UPDATE on the seat row — exclusive row lock, the second transaction physically waits. (3) UNIQUE constraint on (show_id, cinema_seat_id) in show_seat — even if every other safeguard had a bug, the DB would refuse the second insert. With all three, the second user gets a clean 409 Conflict and is offered alternative seats.expires_at = NOW() + 300s. After 5 minutes with no /confirm, the timer fires, the seat atomically transitions HELD → AVAILABLE, and WaitingUsersService dequeues the next waiter. The user who lost their network sees an "expired" message on their next page-load. No double-charge (payment was never started), no stuck seat.enqueued_at timestamp. When any of the 10 HELD reservations expires, ActiveReservationsService notifies WaitingUsers, which pops the head of the queue (the longest-waiting user) and gives them a 5-minute window to claim. If they don't, we wake the next in line. First-come-first-served, enforced by a queue, not by who happens to refresh fastest.