“Design BookMyShow” looks like a catalogue app: list cities, list movies, list showtimes, take a payment, print a QR code. The interviewer lets that version breathe for about a minute, then drops the question the toy design cannot answer: a cricket final goes on sale at 10:00 AM, 2 million people hit the same match’s seat map in the same second, and seat H14 in the same stadium can only be sold to exactly one of them. Two people are staring at the same seat, both tap “pay,” both cards are valid. Who gets it, and how do you make sure the other one never gets a confirmation for a seat that is already gone?
That single question - selling a strictly finite, individually-identifiable inventory under massive concurrent contention, with zero double-selling - is the spine of the whole system. Everything hard here hangs off it: how you hold a seat while a user fumbles with a card without letting that hold last forever, how two requests for the same seat resolve to exactly one winner, how you keep the seat map fast to read for millions while it is being mutated constantly, and how you stop a flash sale from turning into a stampede on one row of one database. Let me build it properly.
Functional Requirements (FR)
In scope:
- Browse. A user browses movies/events by city, picks a movie, sees the theatres and showtimes near them. Read-heavy, cacheable.
- View the seat map. For a chosen show, the user sees the live seat layout - which seats are available, which are booked, which are temporarily held by someone else mid-checkout.
- Select and hold seats. The user picks one or more seats. The system places a temporary hold on those exact seats so no one else can grab them while this user pays. The hold expires if the user does not complete in time.
- Book / pay. The user pays; on success the held seats become permanently booked and a ticket (booking reference + QR) is issued. On failure or timeout the hold is released and the seats return to the pool.
- Guarantee no double-booking. A seat for a given show is sold to at most one booking. Ever. This is the correctness heart of the system.
- Cancel / refund (basic): a booking can be cancelled, returning its seats to inventory subject to policy.
Explicitly out of scope (say it out loud so you own the scope):
- Payment gateway internals. Card networks, PCI vaulting, UPI rails, the ledger and settlement. We integrate a payment provider as a service call and design around its async, sometimes-slow, sometimes-failing nature. We do not build the gateway.
- Recommendations, search relevance, reviews, ratings. Each is its own system.
- Pricing engine / dynamic pricing / offers and coupons. Assume a price per seat class is given. Coupon validation is a separate service call.
- Content ingestion / theatre onboarding. Assume shows, layouts, and seat maps already exist.
The one decision that drives everything: this is a low-total-write, extreme-contention inventory system, not a high-throughput one. BookMyShow does not sell that many tickets per second globally. What it does is concentrate enormous concurrent demand onto a tiny finite set of rows during a handful of hot shows. The defining constraint is not aggregate QPS - it is that hundreds of thousands of people fight over the same few hundred seats at the same instant. That is why seat locking and contention control, not raw scale, is the heart of the design.
Non-Functional Requirements (NFR)
- Scale: ~50M registered users, ~5M DAU on a normal day. Catalogue of hundreds of thousands of shows across tens of thousands of screens. Normal booking volume is modest (a few thousand bookings/sec peak). But a hot on-sale (blockbuster first-day-first-show, a cricket final) drives 2M+ concurrent users hitting one event, with hundreds of thousands of seat-selection attempts in the opening minute against a single show’s few thousand seats.
- Latency: seat-map reads under ~200ms. A seat-hold request (the contended write) must return a clear yes/no in under ~300ms - the user cannot stare at a spinner while someone else takes the seat. Checkout/booking confirmation under a couple of seconds including payment.
- Availability: 99.99% on browse and seat-map reads. The booking path must be highly available but is allowed to fail closed: if we are unsure whether a seat is free, we refuse to sell it. Refusing a sale is acceptable; double-selling is not.
- Consistency: the seat-inventory operation is strongly consistent - linearizable per seat per show. A seat transitions available -> held -> booked exactly once. Everything else (browse catalogue, showtime lists, “seats remaining” counters shown while browsing) can be eventually consistent and cached.
- Durability: confirmed bookings and payments are financial records - durable, replicated, never lost. Holds are ephemeral by design and may be lost on a crash (a lost hold just means a seat frees early, which is safe).
- Correctness invariant (the whole point): for any (show, seat), the number of confirmed bookings is exactly 0 or 1. No overselling under any interleaving of concurrent requests.
Back-of-the-Envelope Estimation (BoE)
Real numbers with the arithmetic, because they dictate where the pressure is.
Users and reads (browse - the easy, high-volume part):
5M DAU, each doing ~20 catalogue/seat-map reads per session
= 100M reads/day
= 100,000,000 / 86,400
≈ 1,160 reads/sec average
Peak (10x on a big release evening) ≈ 12,000 reads/sec
Browse and seat-map reads are the high-QPS path, but they are reads - cacheable, replicable, boring to scale.
Bookings (writes - low volume in aggregate):
Say 2M tickets/day nationwide on a busy day
= 2,000,000 / 86,400
≈ 23 bookings/sec average
Peak, spread across all shows ≈ a few hundred/sec
23 bookings/sec is nothing. If aggregate write rate were the problem, a single database would do. It is not the problem. The problem is the shape of the demand:
The flash-sale contention (the real number that matters):
Cricket final, on-sale at 10:00 AM.
Stadium capacity: ~40,000 seats.
Concurrent users trying to buy in the first minute: ~2,000,000.
=> ~50 people fighting for every single seat.
Seat-selection / hold attempts in the opening 60s: hundreds of thousands.
All hitting one event's seat inventory.
So the interesting load is not 23 writes/sec spread out - it is a spike of ~100k+ contended hold-attempts/minute concentrated on one show’s few thousand rows, with a ~50:1 loser-to-winner ratio. The architecture must make 49 of every 50 attempts fail fast and cleanly on the seat they wanted, and the 1 winner succeed atomically.
Storage:
Per booking record ≈ 500 bytes (ids, seats list, show, amount, status, ts, qr ref)
2M bookings/day * 500 bytes = 1 GB/day ≈ 365 GB/year of bookings.
Seat inventory: per (show, seat) row ≈ 50 bytes.
Hundreds of thousands of shows * ~200 seats avg ≈ low billions of seat rows live at any time,
but a show's seats are relevant only until the show starts, then archivable.
Active working set: shows in the next ~2 weeks, a few hundred million seat rows ≈ tens of GB.
Storage is trivial. This is not a big-data problem; it is a concurrency-correctness problem.
Cache:
Seat maps for hot shows: a hot show's seat map is a few KB.
Even 10,000 hot shows cached fully ≈ tens of MB.
The entire hot working set fits in memory easily.
The takeaways: reads are high-volume but easy (cache and replicate). Writes are low-volume in aggregate but pathologically contended on a few rows during flash sales. The whole design budget goes into making the contended seat-hold operation atomic, fast, and fair, and into shedding the flash-sale stampede before it reaches the inventory. Nothing else here is hard.
High-Level Design (HLD)
The architecture separates the read plane (browse catalogue and seat maps - cached, replicated, eventually consistent) from the inventory plane (the strongly-consistent seat state machine where holds and bookings happen). A booking flows: read the seat map (fast, cached) -> request a hold on specific seats (strong, contended) -> pay -> confirm the hold into a booking (strong, durable).
┌──────────────┐ ┌───────────────────┐
│ User (web/ │ browse / seat map │ Payment Provider │
│ mobile app) │ hold / book │ (external, async)│
└──────┬────────┘ └─────────▲─────────┘
│ │ webhook / callback
┌──────▼───────────────┐ │
│ CDN / Edge │ static + cached seat maps │
└──────┬───────────────┘ │
│ │
┌──────▼───────────────┐ ┌───────────────────┴────────┐
│ API Gateway │ │ Waiting-Room / │
│ (auth, rate limit, │───────▶│ Queue Service │
│ admission control) │ │ (flash-sale token/admit) │
└──────┬───────────────┘ └────────────────────────────┘
│
┌──────┼──────────────────────────────────────────────┐
│ │ READ PLANE │
│ ┌────▼─────────┐ ┌──────────────┐ ┌────────────┐ │
│ │ Catalogue Svc │ │ Seat-Map Svc │ │ Redis / │ │
│ │ (cities, │ │ (live seat │◀──│ cache │ │
│ │ shows) │ │ status read)│ │ (seat maps)│ │
│ └──────────────┘ └──────┬───────┘ └────────────┘ │
└───────────────────────────┼─────────────────────────┘
│ read status
┌───────────────────────────┼─────────────────────────┐
│ INVENTORY PLANE (strong) │
│ ┌────────────────────────▼───────────────────────┐ │
│ │ Booking / Inventory Service │ │
│ │ HOLD (atomic claim of seats, TTL) │ │
│ │ CONFIRM (hold -> booked on payment success) │ │
│ │ RELEASE (TTL expiry / cancel / pay fail) │ │
│ └───────┬───────────────────────────┬──────────────┘ │
│ │ │ │
│ ┌───────▼────────┐ ┌───────▼──────────────┐ │
│ │ Seat-Hold Store │ │ Inventory DB (SQL, │ │
│ │ (Redis, TTL, │ │ seats + bookings, │ │
│ │ atomic ops) │ │ sharded by show_id) │ │
│ └────────────────┘ └───────────────────────┘ │
└────────────────────────────────────────────────────────┘
│
┌──────▼───────────┐ ┌──────────────────┐
│ Reaper (sweeps │ │ Notification Svc │
│ expired holds) │ │ (ticket, QR) │
└───────────────────┘ └──────────────────┘
Browse / seat-map read flow (the easy path):
- User browses cities/movies/showtimes. The Catalogue Service serves this from cache/read replicas - it is slow-changing reference data.
- User picks a show. The Seat-Map Service returns the seat layout with each seat’s status (available / held / booked). Hot shows’ seat maps are cached in Redis and served in a few ms; the cache is updated as inventory changes (with a short TTL as a safety net). This read is eventually consistent - a seat shown “available” might get held a half-second later, and that is fine because the hold step re-checks atomically.
Hold -> pay -> book flow (the hard path):
- User selects seats [H14, H15] and requests a hold. The request hits the Booking/Inventory Service.
- The service atomically claims those exact seats for this user in the Seat-Hold Store, with a TTL (say 8 minutes). Atomic means: either it gets all requested seats or none, and no two users can both claim the same seat. If any seat is already held/booked, the hold fails and the user is told which seats went (re-render the map).
- On a successful hold, the user proceeds to payment. The seats show as “held” to everyone else.
- User pays via the external Payment Provider (async - could take seconds, could fail). While paying, the hold TTL is ticking.
- On payment success (provider webhook/callback), the service confirms the hold: within a single strongly-consistent transaction it flips the seats from held -> booked in the durable Inventory DB and writes the booking record. The QR/ticket is issued and the user notified.
- On payment failure, user abandonment, or TTL expiry, the hold is released and the seats return to the available pool. A background Reaper guarantees release even if nothing else fires.
Flash-sale admission: for a hot on-sale, users do not hit the inventory directly. They pass through a Waiting-Room / Queue Service that admits a controlled rate of users into the booking flow (a fair virtual queue), so the inventory plane sees a manageable stream instead of a 2M-user thundering herd.
The key insight: the seat map is a fast eventually-consistent read, but the hold is a strongly-consistent atomic claim, and the booking is a durable transaction. You serve millions cheaply from cache, and you pay the cost of strong consistency only on the small, contended hold-and-confirm operations - and you throttle the herd before it gets there.
Component Deep Dive
The hard parts, naive-first then evolved: (1) seat locking without double-booking, (2) holds with TTL and how they expire, (3) the confirm/payment step and its failure modes, (4) surviving flash-sale contention.
1. Seat locking without double-booking
This is the correctness core. Given a (show, seat), sell it to exactly one booking under arbitrary concurrency.
Naive approach: read, check, then write.
seats = SELECT status FROM seats WHERE show_id=? AND seat IN ('H14','H15')
if all available:
UPDATE seats SET status='booked', booking_id=? WHERE show_id=? AND seat IN (...)
Where it breaks:
- The classic race (check-then-act). Two requests both
SELECTH14, both see “available,” bothUPDATE. Both succeed. Two bookings for one seat. This is a textbook time-of-check-to-time-of-use bug, and under flash-sale concurrency it fires constantly, not rarely. - No isolation between the read and the write. The gap between checking and writing is exactly where the second request slips in. Reading in one statement and writing in another, with app logic between, cannot be safe no matter how fast the app is.
First evolution: a conditional (compare-and-set) write. Fold the check into the write so it is one atomic statement:
UPDATE seats
SET status='held', held_by=?, hold_expires_at=?
WHERE show_id=? AND seat='H14' AND status='available'
-- rows_affected == 1 -> you got it
-- rows_affected == 0 -> someone else already has it
The AND status='available' makes it a compare-and-swap: the database evaluates the predicate and the write as one atomic operation under row locking. Only one of two concurrent updates can find the row available; the other sees 0 rows affected and loses. This single line eliminates the classic double-booking race for one seat. No two writers can both win.
But a booking is usually multiple seats, and they must be all-or-nothing. Two problems remain:
- Atomicity across seats. A user wants [H14, H15]. Someone else takes H15 between your H14 update and your H15 update. Now you hold H14 but not H15 - a partial hold, a bad state.
- Deadlock under contention. If request A locks H14 then H15, and request B locks H15 then H14, they can deadlock.
The answer: a single transaction, all seats claimed together, in a deterministic order.
BEGIN;
-- always lock seats in a canonical order (e.g. sorted seat id) to avoid deadlock
UPDATE seats
SET status='held', held_by=:user, hold_expires_at=:now+8min
WHERE show_id=:show AND seat IN ('H14','H15') AND status='available';
-- require that we claimed EXACTLY the number of seats we asked for:
IF rows_affected != 2 THEN ROLLBACK; -- someone had one of them; take none
ELSE COMMIT;
- All-or-nothing comes from doing every seat in one transaction and asserting
rows_affected == requested_count. If we did not get all of them, weROLLBACKand the user gets a clean “seats no longer available, here is the refreshed map.” - Deterministic ordering (sort the seat ids before locking) prevents the ABBA deadlock: every transaction acquires row locks in the same order, so a cycle cannot form.
- Row-level locking, not table locking. We lock only the specific seat rows, so bookings for different rows/shows never block each other. Contention is scoped to the actual contended seats.
Where does the lock live - the database or a distributed lock? Two schools:
| Approach | How | Pros | Cons |
|---|---|---|---|
| DB conditional update | UPDATE ... WHERE status='available' in a txn | ACID, durable, single source of truth, no lock/data drift | contended rows hit the DB hard during flash sales |
| Distributed lock (Redis) | SET lock:{show}:{seat} NX EX before touching DB | absorbs contention in memory, very fast, TTL built in | lock and data can diverge; Redis is not the source of truth; needs careful expiry |
The pragmatic design uses both, at different layers: an in-memory atomic seat-hold store (Redis) as the fast, contended front line that decides the winner in memory, and the durable SQL inventory as the source of truth that the winner writes through to on confirm. Redis handles the 50:1 stampede cheaply; the database only ever sees the winners. I will make that concrete under holds (next) and confirm (after).
The non-negotiable principle regardless of implementation: the transition into “held” or “booked” must be a single atomic compare-and-set on the seat, so exactly one concurrent request can succeed. Everything else is optimization around that invariant.
2. Holds with TTL - claiming a seat while the user pays
A user does not book instantly - they pick seats, then spend a minute or two on payment. During that window the seats must be theirs and no one else’s, but not forever (an abandoned checkout must not lock seats permanently).
Naive approach: no hold - just book on payment. Let anyone select any available seat; only lock it at the final “confirm payment” step.
Where it breaks:
- Wasted user effort / bad UX. Two users select H14, both go to pay, one loses at the last step after entering card details. Infuriating, and common under contention.
- Selection is not a claim. Without a hold, “I selected this seat” means nothing, so the seat map shown during selection is a lie - the seat could vanish at payment time for almost everyone.
So we need an explicit, time-bounded hold: a soft lock that reserves the seat for one user for a bounded window.
The answer: atomic hold in Redis with a TTL, confirmed through to the DB on payment.
- Atomic multi-seat hold in Redis. Each seat has a key
hold:{show_id}:{seat_id}. Claiming is aSET key user_token NX EX 480(set-if-absent, 8-minute expiry). To claim multiple seats atomically we run a Lua script (Redis executes it atomically, single-threaded) that checks all requested seat keys are free and sets them all, or sets none:
-- KEYS = seat keys for this show; ARGV = user_token, ttl_seconds
for i, key in ipairs(KEYS) do
if redis.call('EXISTS', key) == 1 then
return 0 -- some seat already held/booked -> fail, set nothing
end
end
for i, key in ipairs(KEYS) do
redis.call('SET', key, ARGV[1], 'EX', tonumber(ARGV[2]))
end
return 1 -- got all seats
Because Redis runs the script atomically, there is no interleaving: two concurrent holds for overlapping seats cannot both succeed. This decides the flash-sale winner in memory, in microseconds, without touching the database.
TTL is the auto-release. The 8-minute expiry means an abandoned checkout self-heals: Redis drops the keys, the seats are free again, no cleanup job strictly required for correctness (though we add one as belt-and-suspenders, below). The TTL is the single most important design element for holds - it makes “forgot to release” impossible by construction.
The seat map reflects holds. The Seat-Map Service shows a seat as “held/unavailable” if a Redis hold key exists (or the DB row is held/booked). This is eventually consistent for readers but that is fine - the authoritative re-check happens atomically at hold time.
Why not hold in the SQL row with
status='held'andhold_expires_at? You can, and the DB remains the source of truth, but then every one of the 50:1 losing hold-attempts is a database write against the hottest rows in the system during a flash sale. Fronting holds with Redis means the database sees far less contention; only confirmed winners write through. In a smaller system (no flash sales) the DB-row hold with anhold_expires_atcolumn and a sweeper is perfectly adequate and simpler - a legitimate design choice. State the trade-off: Redis-fronted holds for extreme contention, DB-row holds for simplicity.Extending / releasing. If the user needs more time (rare, bounded), we can extend the TTL once. On explicit “back” or payment failure, we proactively
DELthe keys so seats free immediately rather than waiting out the TTL.
The Reaper (safety net). Even with Redis TTLs, the durable DB can hold rows marked held if we ever write holds there, and we want a defensive sweep for orphaned state (e.g. a hold recorded in the DB whose Redis key expired due to a confirm crash). A background Reaper periodically finds status='held' AND hold_expires_at < now seats and releases them. This is belt-and-suspenders: the TTL is the primary mechanism; the Reaper catches anything that slipped.
3. The confirm step and payment failure modes
The dangerous moment is turning a hold into a booking across an external, slow, unreliable payment provider.
Naive approach: hold -> call payment synchronously -> on 200, mark booked.
hold(seats)
resp = payment_provider.charge(card, amount) # blocks, may take seconds
if resp.ok: UPDATE seats SET status='booked'
Where it breaks:
- The provider is slow and flaky. Charges can take seconds; the connection can drop after the charge succeeded but before you get the response. Now the money moved but you do not know it. If you assume failure and release the seats, you have charged someone for nothing. If you assume success, you might book a seat for an unpaid order.
- TTL vs payment race. The hold TTL can expire while the payment is in flight. The seat frees, someone else grabs it, then the first user’s payment succeeds. Two people paid for one seat - the exact failure we must never allow.
- Non-idempotent confirm. A retried webhook or a double callback books the seat twice or double-charges.
The answer: an order state machine, idempotent confirm, and reconciliation - with the hold owning the seat until the order is terminal.
- Booking is an order with an explicit state machine, not a boolean:
CREATED -> PENDING_PAYMENT -> (PAID -> CONFIRMED) | FAILED | EXPIRED
The seats are held from CREATED and only become durably booked on CONFIRMED. A payment_intent_id is generated once per order and passed to the provider as an idempotency key, so retries of the same charge never double-charge.
Handle the TTL-vs-payment race explicitly. This is the subtle one. Two safe options:
- Freeze the hold once payment starts. When the user hits “pay,” transition the order to
PENDING_PAYMENTand mark the hold non-expiring (or extend its TTL well past the payment timeout) so it cannot vanish mid-charge. The seats stay claimed until payment resolves one way or the other. This is the common choice. - Confirm is a conditional transaction that re-verifies ownership. The final flip to
bookedisUPDATE seats SET status='booked' WHERE show_id=? AND seat IN (...) AND status='held' AND held_by=:this_order. If the hold had expired and someone else took the seat,rows_affectedis short of expected, the confirm fails, and we refund rather than double-book. The DB row remains the arbiter.
Use both: freeze the hold at payment start and make confirm conditional. Belt and suspenders, because money is on the line.
- Freeze the hold once payment starts. When the user hits “pay,” transition the order to
Idempotent confirm. The webhook that reports “payment succeeded” can arrive twice. Confirm is keyed by
payment_intent_idand is a no-op if the order is alreadyCONFIRMED. TheUPDATE ... WHERE status='held'is naturally idempotent too - the second run affects 0 rows because the seats are alreadybookedby the same order, which we treat as success.The confirm transaction is the one strongly-consistent, durable write:
BEGIN;
UPDATE seats SET status='booked', booking_id=:bid
WHERE show_id=:show AND seat IN (:seats)
AND status='held' AND held_by=:order; -- conditional: still ours?
IF rows_affected != :n THEN ROLLBACK; refund(); RETURN conflict;
INSERT INTO bookings(...) VALUES (...); -- durable financial record
UPDATE orders SET state='CONFIRMED' WHERE id=:order;
COMMIT;
-- then: DEL Redis hold keys, issue ticket/QR, notify user
Reconciliation for the unknown-state case. When the provider call times out with no answer, we do not guess. The order stays
PENDING_PAYMENT; a reconciliation job polls the provider’sGET /payment/{intent_id}for the authoritative status and then confirms or fails+refunds. The seats stay held until this resolves. We never release seats we might have been paid for, and we never book seats we were not paid for.Fail closed. If we genuinely cannot determine payment state within a bounded window, we mark the order
FAILED, refund any captured amount, and release the seats. Refunding a rare stuck order is acceptable; double-selling is not.
4. Surviving flash-sale contention
The blockbuster on-sale: 2M users, one show, 40k seats, 50:1 contention, all in 60 seconds.
Naive approach: let everyone through to the booking service. Every user loads the seat map and fires hold requests directly at the inventory.
Where it breaks:
- Thundering herd on the hottest rows. Hundreds of thousands of hold-attempts slam the same few thousand seat keys. Even Redis, single-threaded per key, serializes them; the database (if holds hit it) melts. Losers retry, amplifying load.
- Seat-map read storm. 2M users refreshing the seat map means 2M reads/sec of a document that is changing every millisecond. Cache invalidation thrash.
- Unfairness and retries. Without ordering, fast clients and bots dominate; humans on mobile networks never win, then hammer retries.
The answer: admission control (a waiting room), aggressive read caching, and contention-scoped atomic writes.
Virtual waiting room / queue. When a show is flagged hot, users do not go straight to booking. They enter a queue service that issues each user a position and admits them into the booking flow at a controlled rate matched to inventory (e.g. admit N users at a time, roughly the rate seats are actually being sold). Implementable as a token bucket / a sorted-set queue in Redis; the user polls “am I in yet.” This converts a 2M-instant spike into a smooth stream the inventory plane can handle, and it is fair (FIFO-ish by arrival) which kills the retry storm. Cloudflare/Queue-It style. This is the single biggest lever for flash sales.
Serve the seat map from cache and update it, do not recompute it. The hot show’s seat map lives in Redis, updated incrementally as holds/bookings happen (publish seat-status deltas), served to millions in a few ms. Clients get near-real-time status via short polling or a pushed delta stream, not by hammering the DB. Reads are eventually consistent - the authoritative check is the atomic hold.
Decide winners in memory, write only winners to the DB. As in the holds section: the atomic Redis Lua hold decides who gets a seat without a DB write. Only the ~40k eventual winners (and their confirms) ever touch the durable inventory. The database’s write load during the flash sale is the sale size (tens of thousands over minutes), not the demand size (millions). This is what makes the numbers survivable.
Scope contention to the seat, and shard hot shows. All inventory for one show is a natural hot partition; shard the inventory DB and the Redis keyspace by
show_idso one hot show’s storm is isolated on its own shard/nodes and does not degrade every other show. Within a show, per-seat keys mean contention is per-seat, not per-show - two users after different seats never block each other.Shed load early and cheaply. Rate-limit at the API gateway per user/IP; reject obvious bots; return a fast, cacheable “you are in the queue” response rather than doing work. Every request you can answer at the edge is one that never reaches the inventory.
Backpressure over failure. When admission is saturated, hold users in the queue with an honest wait estimate rather than failing their requests. A queued user retries less than a rejected one.
The elegant part: the flash sale is defended in layers, each shedding load before the next. The waiting room turns 2M into a trickle; the cache absorbs the read storm; the in-memory atomic hold decides winners without the DB; and only the actual winners write durably. The finite inventory (40k seats) means only 40k real writes happen no matter how many people show up - the entire job is making the other millions fail fast, fairly, and cheaply.
API Design & Data Schema
The read path is cacheable REST; the booking path is a small set of strongly-consistent, idempotent operations.
REST endpoints
GET /api/v1/cities/{city}/movies
-> [ { movie_id, title, poster, genres, ... } ] (cached, CDN)
GET /api/v1/movies/{movie_id}/shows?city=&date=
-> [ { show_id, theatre, screen, start_time, price_tiers } ] (cached)
GET /api/v1/shows/{show_id}/seatmap
-> { show_id, layout:[[...]],
seats:[ { seat_id:"H14", tier:"gold", status:"available|held|booked" } ] }
(served from Redis cache; eventually consistent)
POST /api/v1/shows/{show_id}/hold
body: { seat_ids:["H14","H15"], user_token }
Idempotency-Key: <client-generated>
-> 200 { hold_id, seat_ids, expires_at } (got ALL seats)
-> 409 { held_seats:["H15"], message:"seats no longer available" } (got none)
POST /api/v1/orders
body: { hold_id, user_id, coupon? }
-> 201 { order_id, amount, payment_intent_id, state:"PENDING_PAYMENT" }
POST /api/v1/orders/{order_id}/confirm
body: { payment_intent_id }
Idempotency-Key: <payment_intent_id>
-> 200 { booking_id, seats, qr_ref, state:"CONFIRMED" }
-> 402 { state:"FAILED", reason } (payment failed -> seats released)
POST /webhooks/payment (from provider)
body: { payment_intent_id, status:"succeeded|failed", ... } (signed, idempotent)
DELETE /api/v1/holds/{hold_id} -> 200 (proactive release on user back-out)
GET /api/v1/queue/{show_id}/status?token= -> { position, admitted:bool, eta_s }
The hold and confirm endpoints are idempotent (via an idempotency key / the payment intent) so client or network retries never double-hold or double-book.
Data stores - the right tool per plane
1. Seat-hold store - Redis, in-memory, TTL, atomic Lua. The contended front line. Keys hold:{show_id}:{seat_id} -> order_token, TTL ~8 min. Multi-seat atomic claim via Lua. Not the source of truth; it decides winners fast and cheaply. Sharded by show_id.
hold:{show}:{seat} -> order_token EX 480 # the soft lock
seatmap:{show} -> cached seat status document (updated on deltas)
queue:{show} -> sorted set of waiting users (admission control)
2. Inventory + bookings - relational (sharded PostgreSQL / a NewSQL like CockroachDB), the source of truth. Seats are a finite, transactional, relational inventory with a strict per-seat state machine; bookings are financial records needing ACID and durability. This is exactly where SQL belongs - overselling is a correctness failure a schemaless eventually-consistent store cannot safely prevent, and confirm needs a real transaction.
Table: seats
show_id BIGINT \
seat_id VARCHAR > PRIMARY KEY (show_id, seat_id)
tier VARCHAR -- gold / silver / recliner
status ENUM(available, held, booked)
held_by UUID NULL -- order/token holding it
hold_expires_at TIMESTAMP NULL
booking_id BIGINT NULL
version INT -- optimistic-lock guard (optional)
Shard key: show_id -- all of one show's seats co-located; hot show = one hot shard
Index: (show_id, status) -- for seat-map reads and reaper sweeps
Table: bookings
booking_id BIGINT PRIMARY KEY -- Snowflake, time-sortable
order_id BIGINT INDEX
user_id BIGINT INDEX
show_id BIGINT INDEX
seat_ids JSONB/array
amount DECIMAL(10,2)
status ENUM(confirmed, cancelled, refunded)
qr_ref VARCHAR
created_at TIMESTAMP
Shard key: show_id (or user_id for user-history reads; see below)
Table: orders
order_id BIGINT PRIMARY KEY
user_id BIGINT
show_id BIGINT
hold_id UUID
payment_intent_id VARCHAR UNIQUE -- idempotency key for payment/confirm
state ENUM(created, pending_payment, paid, confirmed, failed, expired)
amount DECIMAL(10,2)
created_at, updated_at TIMESTAMP
Index: (state, updated_at) -- reconciliation + reaper scans
3. Catalogue - relational, heavily cached / CDN. Cities, movies, theatres, screens, showtimes, layouts. Slow-changing, read-through cache, served at the edge.
Sharding note. The natural shard key for the hot path is show_id: a show’s seats and their contention live entirely on one shard, so a flash sale is isolated to that shard and never degrades other shows. The cost is that “my bookings” (a user’s history across shows) fans out across shards - solved by also writing a secondary index of bookings by user_id (or a separate bookings_by_user table / a CDC-fed read model), keeping the write path sharded by show while user-history reads stay cheap. Different access patterns, different partitioning - resolved with a secondary read model rather than compromising the hot path.
Why SQL for inventory but Redis for holds: they have opposite needs. Holds are ephemeral, extremely contended, and must resolve in microseconds - a durable relational write per losing attempt would melt the DB, so an in-memory atomic store fronts them. Bookings and the seat state machine are financial, transactional, and must never oversell - eventual consistency is exactly wrong there. The winner promotes from the fast in-memory claim into the durable transactional truth. Matching the store to the plane is the whole schema decision.
Bottlenecks & Scaling
Where it breaks first, in order, and the fix for each:
1. The double-booking race (the correctness bottleneck, breaks first logically). Concurrent requests for the same seat.
Fix: atomic compare-and-set on the seat - an in-memory Redis Lua multi-seat claim on the hot path, promoted to a conditional UPDATE ... WHERE status='held' AND held_by=order transaction on confirm. Exactly one concurrent request can win a seat, by construction. Multi-seat holds are all-or-nothing in one atomic operation; seats are locked in canonical order to avoid deadlock.
2. Flash-sale thundering herd (breaks first operationally). 2M users onto one show’s few thousand rows. Fix: a virtual waiting room admits users at a controlled, fair rate, converting a 2M spike into a manageable stream. Winners are decided in memory (Redis), so only the ~40k actual buyers ever write to the durable DB. The demand can be millions; the durable write load is bounded by the finite inventory.
3. Seat-map read storm. Millions polling a rapidly-changing seat map. Fix: serve the seat map from Redis cache updated by deltas, not recomputed from the DB per request; push status deltas or use short polling. Reads are eventually consistent; the authoritative check is the atomic hold, so a slightly stale map is safe.
4. Hold cleanup / stuck seats. Abandoned checkouts must not lock seats forever.
Fix: TTL on every hold (Redis auto-expiry is the primary mechanism, making “forgot to release” impossible), plus a Reaper that sweeps status='held' AND hold_expires_at < now in the DB as a defensive backstop. Proactive DEL on explicit back-out frees seats immediately.
5. Payment slowness and the TTL-vs-payment race. Hold expires mid-charge; provider times out with unknown result. Fix: freeze the hold once payment starts (extend TTL past the payment window) so seats cannot vanish under an in-flight charge; make confirm a conditional, idempotent transaction that re-verifies ownership and refunds rather than double-books if the hold was lost; reconcile unknown-state payments by polling the provider before releasing or booking. Fail closed - refund a rare stuck order rather than oversell.
6. Hot shard. One blockbuster concentrates all load on its shard.
Fix: shard inventory and the Redis keyspace by show_id so a hot show is isolated - its storm never touches other shows. Per-seat keys scope contention to individual seats. Provision hot shows onto dedicated capacity ahead of a known big on-sale.
7. Read scaling for browse. High-QPS catalogue and seat-map reads. Fix: CDN + read replicas + Redis for all slow-changing catalogue data; the read plane scales horizontally and independently of the inventory plane.
8. User-history reads across a show-sharded store. “My bookings” fans out.
Fix: a secondary bookings_by_user read model (a second index / CDC-fed table) so the hot write path stays sharded by show while user reads stay single-shard cheap.
9. Idempotency under retries. Clients and webhooks retry; naive handling double-holds or double-books.
Fix: idempotency keys on hold and confirm (the payment_intent_id for confirm); the conditional updates are naturally idempotent (a re-run affects 0 rows and is treated as the same success). Signed, deduplicated payment webhooks.
10. Single points of failure. Fix: Redis (holds, cache, queue) runs replicated with failover; the inventory DB is replicated (sync replica for the source of truth, read replicas for maps); services are stateless behind the LB. Because the DB is the source of truth and Redis only fronts contention, a Redis failover at worst forces a brief fall-back to DB-row holds (slower, still correct) rather than any double-booking. The system fails closed: uncertainty refuses a sale, never oversells.
11. Consistency of Redis holds vs DB truth. The two can drift (a Redis key expires while a DB confirm is mid-flight).
Fix: the DB is always the arbiter - confirm re-checks status='held' AND held_by=order in the transaction, so a drifted Redis hold cannot cause a double-book; it can only cause a rare refused/refunded order, which the reconciliation and reaper resolve.
Wrap-Up
The trade-offs that define this design:
- Strong consistency only on the seat, eventual everywhere else. Browse, showtimes, and the rendered seat map are cached and eventually consistent; only the seat’s transition into held/booked is a strongly-consistent atomic compare-and-set. We pay for linearizability exactly on the contended operation and nowhere else, which is what keeps the read plane cheap and the correctness invariant absolute.
- In-memory holds front a durable inventory. Redis decides the flash-sale winner atomically in microseconds so the database only ever sees the tens of thousands of actual buyers, not the millions of losers - trading a small chance of Redis/DB drift (resolved by making the DB the arbiter at confirm) for surviving 50:1 contention.
- TTL over cleanup logic. Holds auto-expire, making “a stuck seat locked forever” impossible by construction rather than by a fragile cleanup job - with a reaper as a defensive backstop, not the primary mechanism.
- Admission control over brute-force scaling. A virtual waiting room reshapes a 2M-user instant into a fair, manageable stream, defending a finite 40k-seat inventory in layers (queue, then cache, then in-memory claim, then durable write) instead of trying to scale the inventory to meet the demand - which is impossible because the inventory is finite by definition.
- Fail closed on payment uncertainty. When we cannot tell whether a payment succeeded, we hold the seat and reconcile rather than guess; we would rather refund a rare stuck order than ever sell one seat twice.
One-line summary: a ticket-booking system where browse and seat maps are cheap cached eventually-consistent reads, seats are claimed by an atomic in-memory Redis hold with a TTL that fronts a strongly-consistent, show_id-sharded SQL inventory, holds promote to durable bookings only through an idempotent, conditional, reconciled confirm transaction so a seat is sold exactly once, and flash sales are absorbed by a fair virtual waiting room so only the finite set of real winners ever reaches the database.
Comments