The whole design, in writing
Every step of the build above, written out: the problem each piece solves, the option that was taken and the ones that were not, the numbers, and how it fails in production.
The big idea
How does Ticketmaster avoid double-booking?
When tickets for a hot show drop, thousands of people lunge for the same few good seats in the same second. Sell seat 12C to two of them and you’ve created a furious customer and a refund.
Most of this system is easy — browsing events is just a read-heavy catalog. The hard, interesting core is correctness under contention: guaranteeing a seat goes to exactly one buyer, even under a stampede. Everything we build orbits that one guarantee.
What the new pieces do
- Buyeruser
- Someone trying to grab specific seats for a show. Will be racing thousands of others for the same ones.
- Other Buyerscrowd
- The crowd. The instant tickets drop, they all hit the same handful of good seats at once.
Step 1 · The skeleton
Browse events and seats
Before anyone buys, they browse: find a show, pick a venue, see the seat map and what’s open. This part is a classic read-heavy workload.
Before buying, people browse events, venues and seat maps. What kind of workload is that, and how do you serve it?
Browsing is read-heavy and tolerant of slight staleness (a seat map a second old is fine); forcing strong consistency on it wastes effort and latency. Spend that effort on the write path.
Listing events and rendering seat maps scales like any read-heavy site — cache and replicate. Recognizing browsing is the easy 90% lets you concentrate design effort on the hard buying path.
Re-deriving seat state from raw sales per view is slow and needless — keep an authoritative seat-state store and read (and cache) it. The interesting problem is the contended write, not the read.
A Catalog Service serves events, venues and seat maps, reading availability from Seat Inventory — the store that tracks each seat’s state (available / held / sold). Browsing can be cached aggressively; it’s the buying that’s hard.
Why this piece earns its place
The quiet assumption here is that a seat map can be a second or two old and nobody gets hurt. That holds every day except the one that matters. Cache the availability layer the way you cache the event and venue records and, at an on-sale, you hand an enormous crowd a map that all agrees on which seats are open — so they all lunge at the same handful, and the cache converts staleness into contention on exactly the rows you least want contended. So I’d split the caching by how fast a field actually changes: event, venue and seat geometry are nearly static and can be cached for a long time, while the available overlay gets a very short TTL or a push refresh. And I’d make the contract honest all the way up to the UI — availability is a hint, never a promise. The map is allowed to be wrong; the booking path re-checks, and the interface has to be able to say “gone, pick another” without it reading as a bug.
What the new pieces do
- API Gatewayedge
- Where every request enters. Later it fronts a virtual waiting room to tame the on-sale stampede.
- Catalog Serviceservice
- Serves the browse experience: events, venues, seat maps and which seats are currently available.
- Seat Inventorystore
- The source of truth for every seat's state: available, held, or sold. The one thing that must never double-book.
Step 2 · The hard part
The race condition
Two buyers both see seat 12C as available. Both click "buy." Both requests read available, both write sold. Now it’s sold twice. This is a textbook race condition.
Two buyers both see 12C as available and both click buy — both read "available," both write "sold." What is this, and the core fix?
Caching doesn’t cause or fix this — even with fresh reads, two requests can both read "available" then both write "sold." It’s a concurrency problem, not a staleness one.
Speed doesn’t prevent two writers from both winning; the double-sell happens at the seat-claim, independent of payment. You must make "claim this seat" a single-winner operation.
Read-then-write in two unguarded steps under concurrency is the textbook race. Enforce a single winner — a short-lived hold, then an atomic transaction at purchase. Naming the race is half the answer.
The fix is to make "claim this seat" an operation only one request can win. You cannot let read-then-write happen in two unguarded steps under concurrency. The next steps are two ways to enforce that single winner: a short-lived hold, then a transaction at purchase.
Why this piece earns its place
It’s worth being precise about what “one winner” applies to, because almost nobody buys one seat. A group buys four together, and if the single-winner operation is per seat, two buyers racing the same row can each win two of the four — no double-sell, and still two orders nobody wanted and two refunds. So the unit of the claim is the whole requested set: all or nothing, one operation that takes every seat in the order or takes none, and hands back anything it did get on a partial failure. That also closes the ordering problem hiding underneath it. If two requests grab seats one at a time in different orders, each can end up holding something the other needs and neither can finish — a deadlock you built yourself. Claiming the set in a single operation, or at minimum always in a fixed seat order, is what prevents that. Naming the race is half the answer; the other half is saying out loud what the atomic unit is.
- 1 seat→ 1 buyer
- 2 writers1 winner
- nodouble-sell
Step 3 · Holds & locks
Reserve before you pay
Checkout takes a couple of minutes — entering card details, confirming. You can’t hold a seat open for everyone that whole time, but you also can’t sell it out from under someone mid-payment.
Checkout takes minutes. You can’t hold a seat for everyone that long, but can’t sell it from under someone mid-payment. How?
A permanent lock strands the seat forever if the buyer abandons checkout — abandoned carts would sell out the show. The hold must auto-release.
The booking service places an exclusive hold with a 5–10 min TTL: while held no one else can take the seat, completing turns it into a sale, and if the buyer vanishes the TTL auto-expires and the seat returns to the pool.
Charging multiple buyers for one seat then refunding all-but-one is a terrible experience and an operational mess. Reserve exclusively before payment so only one buyer can pay.
The Booking Service places a short-lived exclusive hold on the chosen seats — a lock in Redis with a TTL (say 5–10 min). While held, no one else can take them. If the buyer completes, the hold becomes a sale; if they vanish, the TTL auto-expires and the seats return to the pool.
Why this piece earns its place
The TTL is the one number on this page I’d expect to be pushed on, so it’s worth saying where it comes from. Set it from the measured distribution of how long real checkouts take — and from the slow tail of that, someone typing a card number on a phone, not the median — because a hold expiring under a buyer mid-payment is the worst outcome this design can generate on its own. Too short and you manufacture that case at volume. Too long and abandoned carts quietly withdraw inventory from sale: during an on-sale, seats can sit held by people who are never coming back, so the show reads as sold out while nothing has actually been sold, and those seats only return long after the crowd has gone. That’s also why I’d resist letting the client extend a hold on demand — and if extension exists at all, it’s capped in total, so the worst case stays a number you chose rather than whatever the browser asks for.
- 5–10 minhold TTL
- exclusivelock
- auto-releaseon expiry
What the new pieces do
- Booking Serviceservice
- The heart of the system: turns 'I want seat 12C' into an exclusive hold, then a confirmed purchase.
- Hold / Lockstate
- Short-lived exclusive holds on seats while a buyer checks out, auto-expiring so abandoned carts free the seats.
Back of the envelope
- hold = Redis lock + 5–10 min TTL
- exclusive while you check out
- complete ⇒ hold becomes sale
- abandon ⇒ TTL auto-releases
- pessimistic for hot seats
- optimistic for rarely-contended ones
Step 4 · Make it correct
A seat is strongly consistent
Holds in a cache are fast, but money is involved — you can’t have the seat’s true state drift or get lost if a node dies. The final claim must be exactly once.
Holds live in a fast cache, but money is involved and a node can die. How do you make the FINAL seat claim exactly-once?
A cache can lose state if a node dies, and the seat’s true state can’t drift or vanish — money is involved. The authoritative claim must be a durable, strongly-consistent write, not a cache entry.
Eventual consistency here means a stale read sells an already-sold seat. Seat state is exactly where you must pay for strong consistency, even at a latency cost.
Treat the seat as strongly consistent: inside one transaction, verify it’s still held by this buyer and flip held→sold atomically. The cache hold gives speed; the transactional write gives the ironclad exactly-once guarantee.
Treat the seat as a strongly consistent resource. Confirm the purchase inside an ACID transaction against the inventory store: re-check the seat is still held by this buyer and flip it to sold atomically. The cache hold gives speed; the transactional write gives the ironclad guarantee.
Why this piece earns its place
The price of putting strong consistency here is that everything now serializes on the hottest rows in the system. Every buyer chasing the same front-row block contends on the same seats in the same partition, and under an on-sale that surfaces as lock waits — which is survivable until the waiting transactions are also holding connections, the pool drains, and bookings for entirely unrelated events start timing out behind them. So the rule is that this transaction stays tiny and local: touch the seat rows, re-check the holder, flip the state, commit. No external call ever runs inside it — the card charge in particular sits outside, which is exactly why the next step needs a saga. And the lock timeout is short with a fast failure, because a buyer told “gone, pick another” in a moment is a much better outcome than a buyer and a database both stuck waiting. Holds help here — most losers are filtered out before they ever reach the transaction — but the transaction has to survive the ones that get through.
Step 5 · Payment saga
Charge, then confirm — or undo
Payment is a slow call to an external provider that can fail, time out, or succeed-but-not-tell-you. You’re holding seats hostage to its answer, and you must never charge twice.
Payment is a slow external call that can fail, time out, or succeed-but-not-tell-you — and you’re holding seats. How do you sequence it?
You can’t put an external provider’s charge and your database in one ACID transaction — the provider is outside your DB. You need a sequence of steps with compensating actions, not one atomic transaction.
Sequence the steps and define a compensating action per failure: on success confirm the sale and emit a receipt; on failure/timeout release the hold so seats return. An idempotency key makes a retried charge never double-bill.
Charging before securing the seat means taking money for seats that may already be gone — mass refunds. Hold the seat first, then charge within the window, then confirm or release.
Run checkout as a saga: hold seats → charge the Payment Service → on success, confirm the sale and emit a receipt on the event bus; on failure or timeout, release the hold so the seats come back. Use an idempotency key so a retried charge never double-bills.
Why this piece earns its place
The dangerous case isn’t the clean failure, it’s the one where you don’t know. The provider takes the charge and the response is lost on the way back. Apply the obvious rule — on timeout, release — and the seats go back into the pool, someone else buys them, and you are holding money for a seat you have now sold to a different person. So a release is only safe once you know no charge landed. On a timeout the compensating path doesn’t simply release: it reconciles with the provider against the idempotency key first, and if a charge is found it either completes the confirmation or refunds explicitly. That sets an ordering constraint worth stating too — the hold has to outlive the payment timeout, or the ordinary slow-but-successful charge races expiry on every purchase.
- idempotentcharges
- confirmor release
- asyncreceipts
What the new pieces do
- Payment Serviceservice
- Charges the card during the hold window, then confirms the seats — or releases them if payment fails.
- Booking Eventsbus
- Carries confirmations, releases and receipts so payment, inventory and notifications stay coordinated.
Back of the envelope
- hold → charge → confirm / release
- a step + a compensating action each
- idempotency key on the charge
- a retry never double-bills
- can’t ACID an external charge
- so sequence + compensate = saga
Step 6 · The stampede
Tame the on-sale rush
A blockbuster on-sale brings a million people in one minute. Even a perfect booking core melts if all of them hit it simultaneously — and the experience becomes a lottery of errors.
A blockbuster on-sale brings a million people in one minute. Even a perfect booking core melts if all hit at once. What do you do?
When demand vastly exceeds the inventory itself (a million buyers, a few thousand seats), more servers just let more people fail faster — and you can’t scale a strongly-consistent seat store arbitrarily. The answer is admission control, not capacity.
A free-for-all turns the on-sale into a lottery of timeouts and errors, hammering the booking core and feeling unfair. You want to shape the load into an orderly, survivable flow.
At peak the gateway diverts buyers into a fair queue and admits them to booking in waves sized to capacity. It converts a thundering herd into a steady trickle and makes fairness explicit — admission control, not more servers.
Front the system with a virtual waiting room. At peak, the gateway diverts buyers into a fair queue and admits them to the booking flow in controlled waves, sized to what inventory and the booking service can handle. The crowd waits in an orderly line instead of crashing the door.
Why this piece earns its place
The thing to get right is the admit rate, and I’d drive it from inventory rather than from a timer. Open loop — admit a fixed number every so often — fails in both directions. Admit faster than seats are being resolved and the wave lands all at once on the same good seats, so most of the people you just let in lose the race anyway and you’ve relocated the stampede rather than shaped it. Admit slower than the show is selling and you keep an enormous queue waiting for an event that is already gone, which is worse than telling them the truth: the queue’s job is fairness, and a fair queue for nothing is just a slower way to disappoint everyone. So the control signal is the booking core’s own state — seats still unclaimed, holds converting or expiring — and the line drains at whatever rate that supports, shows remaining inventory honestly, and closes when there is nothing left to admit anyone for.
- 1M+in 60s
- fairqueue
- wavesof admits
What the new pieces do
- Waiting Roomservice
- A fair queue that admits buyers in controlled waves during a hot on-sale, instead of letting everyone storm at once.
Back of the envelope
- 1M+ buyers in 60s
- vs a few thousand seats
- queue + admit in waves
- sized to booking capacity
- admission control, not more servers
- thundering herd → steady trickle
Step 7 · The sharp edges
Scale, fairness & failure
Big venues and many simultaneous events strain one inventory store; bots try to scalp; and holds can leak if a booking node dies mid-flow.
Shard inventory by event so each show’s hot seats live on their own partition. Add bot defenses (CAPTCHAs, per-account limits) and queue fairness to fight scalpers. Run a sweeper that reclaims expired or orphaned holds, so a crashed checkout never permanently strands a seat.
Why this piece earns its place
Sharding by event works because of an assumption worth saying out loud: a purchase never spans two shows, so every hold and every claim lands inside one partition and the transaction from earlier stays single-shard. That part holds. What doesn’t hold is the idea that it balances anything. Event demand is about as skewed as distributions get — one stadium on-sale can outweigh everything else on the platform at once — so shard-per-event is a routing key, not load balancing, and a hot event sharing a partition with a thousand small ones takes those thousand down with it. So the big show gets isolated capacity of its own, and it gets moved there before the on-sale, never during. The sweeper needs the opposite instinct, because it is the only component that deletes state it didn’t create: it should act on a definite signal and nothing softer. It can’t cause a double-sell — the claim re-checks the holder — but an eager sweeper can absolutely take a seat away from someone who was still buying it.
You did it
You just designed Ticketmaster.
Everything you assembled, in order
- A read-heavy catalog serves events and seat maps over seat inventory.
- The core challenge is a race condition: one seat, one winner.
- Short-lived holds (Redis + TTL) reserve seats during checkout.
- An ACID transaction makes the final seat claim exactly-once.
- A payment saga charges idempotently, then confirms or releases.
- A virtual waiting room shapes the on-sale stampede into fair waves.
- Shard by event, fight bots, and sweep orphaned holds for the real world.