If you remember five things
The whole answer in one glance. Everything below is the defense of these five calls.
- Split by consistency. Browse and seat-map reads go eventually consistent and cached hard. The seat ledger stays strongly consistent. This split is the backbone of the whole design.
- Layered reserve. Redis SET NX is a fast advisory filter that kills doomed attempts at memory speed. A database version check is the real referee. Fast in front, authoritative behind.
- Holds are real rows. Model the hold with expires_at, sweep it on a tight loop, and make the checkout transaction the final referee on the expiry race so nobody pays for a reclaimed seat.
- Waiting room first. Shape 500k of demand down to a few thousand concurrent shoppers before it ever touches inventory. This is what makes the 25k-QPS number survivable.
- Payment off the lock. A queue and a worker drive the slow, flaky provider so a Stripe stall degrades conversions but never freezes a seat.
1Clarifying the requirements#
Pin down what "Ticketmaster" means for this hour before drawing a single box. The scope is huge and most of it is boring. Carve it down to the one surface that is actually hard.
Candidate: Are we doing general admission where any ticket is interchangeable, or reserved seating where seat 14C is a specific object someone picks on a map?
Interviewer: Reserved seating. The seat map is the interesting part.
Candidate: Got it. That matters a lot, because general admission is just a counter you decrement, and reserved seating is 60k individually contested rows. Do I need to build the payment processor itself, or can I assume a third party like Stripe and focus on inventory and the checkout flow around it?
Interviewer: Assume a payment provider. I care about what happens to the seat while payment is pending.
Candidate: Good, that is the interesting question anyway. Last one. Is a flash sale, think a stadium tour going on sale at 10am sharp, in scope? Because that single assumption changes the entire design.
Interviewer: Yes. Assume the worst: half a million people hitting one event at the same second.
What those answers just told me:
- The interview is about contention on one hot event, not CRUD on a catalog.
- "Reserved seating" means 60k individually contested objects, not a counter I decrement.
- "What happens while payment is pending" is the interviewer pointing straight at the hold state and the expiry race.
- "Half a million on one event" is the interviewer handing me the thesis: the flash sale is the design.
Functional requirements
- Browse events and venues, search by city, artist, date.
- View a live seat map for an event, with seats marked available, held, or sold.
- Reserve specific seats for a short window (a hold) so the user has time to pay.
- Check out: convert a hold into a confirmed booking after payment.
- Release the hold automatically if the user does not pay in time.
- Prevent any seat from being sold twice. Non-negotiable.
Non-functional requirements
- Strong consistency on inventory. I trade availability for correctness on the seat itself, every time.
- Low latency on reads: seat map and browse feel instant, under 200ms.
- Survive a flash sale: 500k concurrent users slamming one event.
- High availability for browsing even when booking is under stress.
- Auditability: every state change on a seat traces to who, when, and why.
Consistency is not one setting. Browsing can be eventually consistent and cached hard. The seat ledger cannot bend an inch. Strong where it counts, loose everywhere else, is the backbone I come back to in every deep-dive. Name it in the first two minutes and the rest of the interview has a spine.
This opening is scored before you draw a single box. I am listening for whether you separate the hard write path from the cheap read path on your own, and whether you name the flash sale as the enemy without me pushing. Start sketching login and a catalog CRUD and I have quietly downgraded you. Name the hard part first, then go spend your time there.
2Back-of-the-envelope estimation#
Get the load in your head, because the numbers decide which problem to solve. Two very different traffic profiles live here: steady-state browsing and flash-sale spikes. They want opposite things, and that is the point.
You are not here to compute storage to three decimals. You are here to surface one load-bearing fact: the aggregate QPS is trivial, but a flash sale jams a quarter-million ops per second onto one event's shard. That concentration, not the total, is why this is a Hard question. Find it fast, then go fight it.
Steady state.
- 100M registered users, 5M daily actives, mostly browsing.
- 20 read actions per active user per day: 5M x 20 = 100M reads/day.
- Average read QPS: 100M / 86,400 is about 1,160. Call it ~1,200, maybe 5,000 at peak.
- That is easy. A couple of read replicas and a cache handle it in their sleep.
Writes.
- Bookings are rare next to reads. Say 200k bookings/day across all events.
- Average write QPS: 200k / 86,400 is about 2.3. Tiny.
That average is a lie, and designing to it fails the interview. The whole game is the spike, so size the spike.
Flash sale.
- A stadium goes on sale: 500k people want 60k seats, so 88 percent leave empty-handed in the same 60 seconds.
- In the first minute everyone is loading the seat map (reads) and trying to grab a seat (reserve writes).
- 500k users x 3 reserve attempts in 60s = 1.5M attempts = 25,000 write-attempt QPS.
- 500k users polling the map every 2s = 250,000 read QPS, all pointed at ONE event.
Storage (not the problem, say so out loud).
- A seat record is small, maybe 200 bytes (ids, status, price, version).
- Even 1M events x 60k seats x 200 bytes is about 12 TB for seat inventory.
- Bookings at 200k/day x 1KB x 5 years is about 365 GB.
- Both sit comfortably on sharded Postgres. Storage is not the fire. Contention is.
3Core APIs#
Keep the surface small and the semantics sharp. The reserve call is the one that matters, so design everything else to stay out of its way.
GET /v1/events?city=austin&from=2026-12-01&artist=phoenix
GET /v1/events/{eventId}/seatmap
POST /v1/events/{eventId}/holds
POST /v1/holds/{holdId}/checkout
DELETE /v1/holds/{holdId}
The hold request. The idempotency key is doing heavy lifting:
- During a flash sale, clients retry on every timeout.
- A client that retries must not end up holding two different seat sets.
- The key makes the retry a no-op that returns the first result.
{
"seatIds": ["sec-A-row-7-14", "sec-A-row-7-15"],
"idempotencyKey": "9f2c1b0a-user42-attempt1"
}
A successful hold response tells the client exactly how long it has, so the on-screen countdown runs off the server clock, not a guess:
{
"holdId": "hold_88a1",
"seatIds": ["sec-A-row-7-14", "sec-A-row-7-15"],
"status": "HELD",
"expiresAt": "2026-11-03T10:03:00Z",
"totalAmount": 24000
}
Two semantics worth defending:
- All or nothing on the seat set. If any seat is taken, the whole request fails atomically with 409 and the list of which seats collided, so the UI grays them out and lets the user pick again.
- No partial holds. Getting 2 of the 4 seats you wanted and sitting next to strangers is a terrible experience. I will not ship it.
4High-level design#
Split the system into a read path that is cheap and cache-heavy and a write path that is careful and consistent. They touch different storage on purpose, because they have opposite requirements, and I refuse to let the easy read path dictate anything about the hard write path.
Read path.
- Search hits an inverted index (think Elasticsearch) for event discovery.
- The seat map is served from Redis, not the database, because 250k reads/sec on one event would melt Postgres and starve the write path of connections.
- The Inventory DB publishes changes through a change stream that refreshes the Redis seat map, so the view is fresh within a second or two.
- Browsing being slightly stale is fine: nobody is harmed by seeing a seat as available a second after it was grabbed; they just lose the race at reserve time and get a clean 409.
Write path.
- The Booking Service owns the truth.
- A reserve acquires a short-lived lock and writes a hold to the Inventory DB inside a transaction.
- Checkout drops an order onto a queue; a payment worker talks to the provider, then confirms the booking.
- The queue decouples the slow, flaky external payment call from the fast inventory operation. That is the single reason the seat lock never waits on Stripe.
Why two separate Redis roles.
- One Redis is the read cache: the seat-map image that 250k pollers hit.
- The other is the hold-lock coordinator for the write path.
- Different jobs, different load shapes, often different clusters in production so a read storm cannot evict the lock keys.
5Data model#
The inventory lives in a relational store because I want real transactions and real constraints, not application code pinky-promising correctness. Here are the core tables.
events
| field | type | notes |
|---|---|---|
| event_id | uuid (PK) | |
| venue_id | uuid | FK to venues |
| artist | text | indexed for search |
| starts_at | timestamptz | |
| on_sale_at | timestamptz | drives the flash-sale gate |
| status | enum | scheduled, on_sale, sold_out |
seats
| field | type | notes |
|---|---|---|
| seat_id | uuid (PK) | |
| event_id | uuid | FK, part of shard key |
| section | text | A, B, floor |
| row | text | |
| number | int | |
| price_cents | int | |
| status | enum | available, held, booked |
| hold_id | uuid null | set while held |
| version | int | optimistic-lock counter |
holds
| field | type | notes |
|---|---|---|
| hold_id | uuid (PK) | |
| user_id | uuid | |
| event_id | uuid | |
| expires_at | timestamptz | TTL, usually now + 3 min |
| status | enum | held, converted, expired |
| idempotency_key | text | unique per user |
bookings
| field | type | notes |
|---|---|---|
| booking_id | uuid (PK) | |
| hold_id | uuid | one-to-one with the hold |
| user_id | uuid | |
| seat_ids | uuid[] | the seats bought |
| payment_id | text | provider reference |
| amount_cents | int | |
| created_at | timestamptz |
Two design calls worth saying out loud:
- The version column on seats is the quiet hero. It lets me do optimistic concurrency without holding a database row lock across a user's thinking time, which can be minutes.
- Shard the whole inventory by event_id. One event's 60k seats and all of its contention land on one shard, deliberately, so a flash sale cannot take down the other 999,999 events sharing the cluster. The hot event gets to be hot in its own corner.
6Deep dives#
6.1 Reserving a seat without ever double-selling#
This is the heart of it. If I only get one deep-dive right, it is this one.
- Two people click seat 14C at the same millisecond.
- One must win cleanly, the other must get a fast, honest no.
- Both must never, under any failure, end up with 14C.
The naive move is correct but falls over.
- A database transaction with SELECT ... FOR UPDATE on the seat rows is correct, and I want to say clearly that it is correct.
- But it serializes everyone on the hot event, and the row locks pile up.
- Under 25k attempts per second, the lock queue becomes the bottleneck, connections starve waiting their turn, and healthy requests time out behind losers.
- So I keep the correctness and add a cheaper gate in front of it.
My approach layers two cheap gates ahead of the expensive one.
- Gate one, Redis lock per seat as a fast filter.
SET seat:{id} {holdId} NX PX 180000. If the NX fails, someone else is grabbing the seat right now, so I reject immediately without ever touching Postgres. This absorbs the thundering herd at memory speed and keeps 90 percent or more of the contention off the database. Most of those 25k attempts per second are doomed anyway (only 60k seats exist), so kill the losers cheaply before they reach the expensive layer. - Gate two, the database is the referee. The Redis lock is only an optimization, not the source of truth. Redis can lose a key on failover, or a partition can hand the same lock to two nodes. So I still confirm in one DB transaction using the version column: update the seat rows to held only if they are still available at the version I read. If the updated row count does not equal the number of seats requested, somebody beat me, I roll back and release the Redis locks.
Why this layering earns points.
- Fast-but-advisory in front of slow-but-authoritative is the exact thing a strong candidate articulates.
- Redis thins the line; the database is the referee that never lets two people through.
- It generalizes to every hot-inventory problem, which is why the interviewer is listening for it.
Reveal the answer
6.2 Expiring holds so seats come back#
A hold is a promise: "this seat is yours for 3 minutes." If the user wanders off, closes the tab, or their payment stalls, the seat must return to the pool automatically and quickly.
- In a flash sale, a released seat is instantly wanted by thousands still in line.
- A 3-minute hold that leaks into a 30-minute dead reservation is how you sell out a show with 500 empty seats.
Do not trust a Redis TTL to drive business state.
- The Redis key expiring is a hint, not an event I can safely act on.
- Redis keyspace notifications are best-effort and can be dropped.
- Instead I store expires_at on the hold row, the authoritative clock, and run a sweeper against the database.
Keep the loop tight and the batches small.
- Freed seats reappear within a few seconds, not minutes.
- Small batches keep any single sweeper transaction short, so it does not contend with live reserves on the same shard.
The race that bleeds money: expiry at second 180 vs checkout at second 179.5.
- Resolve it in the checkout transaction by re-checking the hold status and expires_at under that same transaction.
- If the sweeper already expired it, checkout fails cleanly and the user is told their hold timed out, with the seats offered back if still free.
- If checkout wins, it flips the hold to converted and the sweeper's conditional update finds nothing to expire.
- The database transaction is the referee: both can try, only one commits. Nobody ever pays for a seat that was already handed back.
6.3 Surviving the flash sale (the hot event)#
A single event pulling 250k reads/sec and 25k write-attempts/sec needs active defense, not a bigger box. You cannot vertically scale your way out of 500k people wanting 60k seats; the math does not bend.
Read side: never let the seat map touch Postgres on the hot path.
- Serve the seat map entirely from Redis, and the coarse "is this section mostly sold" overview from the CDN with a 1-to-2 second TTL.
- The change stream pushes updates into Redis; clients poll Redis-backed endpoints.
- The fancy version pushes deltas over WebSocket so 250k clients are not polling at all, turning 250k QPS into a trickle of change notifications.
- Honest polling with a 2-second cache is simpler and good enough for most loops, so I mention WebSocket as the optimization, not the starting point.
Write side: the real weapon is a virtual waiting room.
- When demand is 500k and supply is 60k, never let 500k requests touch the booking path, because 440k are going to fail anyway and I do not want to pay database cost to say no.
- The on-sale gate queues arriving users and admits them in controlled waves, say 5k active shoppers at a time.
- Everyone else sees a "you are number 41,882 in line" screen with a position token that advances as waves clear.
Admission is token-based.
- The waiting room issues a signed token (a short JWT or HMAC-signed position) that the API Gateway verifies on every reserve.
- No valid token, no reserve, full stop.
- That turns an unbounded stampede into a steady, bounded, verifiable stream, and it means a scripted bot cannot skip the line by hammering the reserve endpoint directly.
Even inside the admitted wave, contain the blast radius.
- Keep per-event rate limits.
- Shard inventory by event so the hot event's pain stays in its own shard and its own Redis slot.
- If one shard is running hot it does not matter to anyone else; the other shards are idle and the rest of the catalog sells normally. Blast radius: one event.
7Bottlenecks and trade-offs#
The hot shard.
- All of one event's contention lands on one inventory shard by design: good for blast-radius containment, but that shard must handle the full reserve load for the biggest event on earth.
- My defense is the waiting room capping concurrency to a few thousand, plus the Redis pre-filter keeping most rejected attempts off the database.
- If even that is not enough for a truly enormous on-sale, I pre-partition the seat map into sections on different lock slots, spreading the write load while keeping each seat's truth in exactly one place.
- I would not reach for that until the waiting room genuinely cannot cap low enough, because splitting a seat map adds real complexity.
Redis as a lock, not a source of truth.
- I treat the Redis lock as advisory. If it fails over and drops keys, or a partition double-grants, I still do not double-sell, because the database version check is the real guard.
- The cost: under a Redis outage my reject rate goes up (more attempts reach the DB and collide there) and latency climbs toward the SELECT-FOR-UPDATE worst case.
- That is exactly the right trade: slower and correct beats fast and wrong when the wrong outcome is two strangers holding the same seat at the same show.
Consistency vs availability on browsing.
- The seat map a user sees can be a second or two stale. I accept that without hesitation.
- Strongly consistent reads of a 60k-seat map at 250k QPS are not worth building and would compete with the writes I actually care about.
- The honest 409 at reserve time is the safety net: stale reads never cause a bad sale, only a lost race, and the UI grays out the taken seats and asks the user to pick again.
Payment latency.
- The external provider can take several seconds or fail outright. I never hold a database row lock across that call, ever.
- The seat sits in held state (not booked), the order is on a queue, and a worker drives the payment asynchronously.
- If payment fails or times out, the hold expires on its normal 3-minute clock and the seat returns through the sweeper.
- A Stripe slowdown degrades conversions but never locks up seats.
Idempotency everywhere.
- Clients retry aggressively during spikes, often automatically on any timeout.
- Every mutating call carries an idempotency key, and holds enforce uniqueness per user key, so a double-submitted reserve returns the same hold instead of grabbing two seats and charging for both.
- Skip this and your flash sale quietly produces phantom double-holds that eat inventory and generate support tickets for a week.
Shape the demand before it reaches the lock, and let the database be the only referee that matters.
8Summary#
The decisions that matter:
- Split by consistency need. Cache-heavy eventually-consistent reads, strongly-consistent writes, on different storage so neither compromises the other.
- Shard inventory by event_id so a flash sale's contention stays on one shard and the rest of the catalog never feels it.
- Layer the reserve. Redis SET NX as a fast rejection filter, then a database version check as the real guard. Redis makes it fast; the DB keeps it correct even when Redis fails.
- Model the hold as a real row with expires_at, swept by a tight 2-second loop, with the checkout transaction as the final referee on expiry races so nobody pays for a reclaimed seat.
- Put a virtual waiting room in front of the on-sale to shape 500k of demand down to a few thousand concurrent shoppers before it reaches inventory. This is the one that makes the 25k-QPS number survivable.
- Decouple payment with a queue and a worker so a slow provider never blocks a seat lock.
Get those six right and you have answered the real question, which was never "how do I sell tickets." It was "how do I never sell the same seat twice while a stadium's worth of people fight over it in one minute."
"Redis fails over and loses the lock keys mid-sale. Do you double-sell?"
"The sweeper expires a hold at second 180 while the user checks out at 179.5. Who wins?"
"500k people, 60k seats. How do you keep the booking service from falling over?"
"The payment provider hangs for 8 seconds. What happens to the seat?"
"A client retries a reserve on timeout. Does it grab two seat sets?"
What you can now explain in the room
- Why the seat ledger stays strongly consistent while browsing goes eventually consistent and cached.
- Why SELECT FOR UPDATE is correct but falls over, and how the Redis filter plus version check fixes it.
- Why the Redis lock is advisory and the database version check is the only referee that matters.
- How to model a hold as a real row and resolve the expiry-vs-checkout race in one transaction.
- Why a virtual waiting room, not a bigger box, is what makes 25k contended writes per second survivable.
- Why decoupling payment with a queue keeps a slow provider off the seat lock entirely.
For related walkthroughs, see our guides on designing a flash-sale store, a distributed lock service, and an airline seat booking system.
Get a prep plan built around your Design Ticketmaster interview
Tell us the role and your interview date. We will send back a dated, day by day plan. Free.