Home › High-Level Design › Design Ticketmaster
D

Design Ticketmaster

You are building the thing that sells 60,000 concert seats in 90 seconds and never sells the same seat twice. That last clause is the entire interview. Anyone can build a store. The hard version is a store where every item is unique, demand spikes 1000x in one minute, and double-selling a single seat is a legal problem, a chargeback, and a PR disaster at once. The candidates who pass figure out fast that this is not an e-commerce checkout. It is a distributed locking problem wearing a seat map, and they spend their 45 minutes there.

High-Level DesignHard14 min read

If you remember five things

The whole answer in one glance. Everything below is the defense of these five calls.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
The split that runs the whole design

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.

From the interviewer's chair

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.

The point of this section

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.
~275k ops/s
The number that drives the designReads plus write-attempts, all colliding on one event's shard in the first minute. Spread across a million events it is nothing. Jammed onto one row range, it is the whole reason this is Hard. Remember this one; it comes back in every deep-dive.

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.

flowchart LR U[Client] --> CDN[CDN] U --> GW[API Gateway] GW --> SRCH[Search Service] GW --> MAP[Seatmap Service] GW --> BK[Booking Service] SRCH --> ES[(Search Index)] MAP --> RC[(Redis Seatmap Cache)] BK --> INV[(Inventory DB)] BK --> RL[(Redis Hold Locks)] BK --> Q[Order Queue] Q --> PAY[Payment Worker] PAY --> PSP[Payment Provider] INV --> CDC[Change Stream] CDC --> RC
The whole system at a glance. The read path (left) is cache-fed and eventually consistent; the write path (right) is the Booking Service guarding the Inventory DB. The one seam that matters is the Order Queue: it keeps the slow payment provider off the seat lock entirely.

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.
sequenceDiagram participant U as User participant B as Booking Service participant R as Redis Locks participant D as Inventory DB U->>B: POST holds seat 14C B->>R: SET seat 14C NX R-->>B: OK locked B->>D: UPDATE seat SET held WHERE available D-->>B: 1 row updated B->>D: INSERT hold expires in 3 min B-->>U: 200 HELD until 10:03
The happy-path reserve. Redis makes the common case fast; the database keeps it correct. A failed SET NX returns 409 at memory speed before the DB step, and a zero-row UPDATE (Redis stale after failover) rolls back and still returns an honest 409.

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.
Pause before you read on: Redis fails over mid-sale and silently drops a batch of lock keys. Two users now both pass the SET NX for seat 14C. What saves you?
Reveal the answer
The database version check. Both users reach the single DB transaction that updates seat 14C to held only if it is still available at the version they read. Exactly one UPDATE touches a row; the other touches zero, rolls back, releases its Redis lock, and returns a clean 409. Redis dropping keys raises the reject rate (more attempts collide at the DB) but can never produce a double-sell. Saying this unprompted, that the lock is advisory and the DB is authoritative, is the staff-level signal here.

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.
flowchart TD S[Hold Sweeper every 2s] --> Q{Find holds past expires_at} Q -->|found| T[Begin transaction] T --> R[Set seats back to available] R --> H[Mark hold expired] H --> P[Publish seat freed event] P --> C[(Update Redis seatmap)] Q -->|none| S
The hold sweeper. A tight 2-second loop grabs a small batch of expired holds and, in one transaction, flips the seats back to available, marks the hold expired, and publishes a seats-freed event that refreshes the Redis seat map.

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.
5k, not 25k
The decision that makes the sale survivableShape the demand before it reaches inventory, so the Booking Service only ever sees a load it can actually handle. With a 5k cap, the reserve path sees thousands of QPS, not 25k, and the Redis-plus-version scheme from 6.1 runs comfortably inside its budget.

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."

Follow-ups the interviewer will ask (and your answers)
"Redis fails over and loses the lock keys mid-sale. Do you double-sell?"
No. The Redis lock is advisory, an optimization to thin the line. The authoritative guard is the database version check inside the reserve transaction: a seat flips to held only if it is still available at the version I read. Two users who both pass a stale SET NX both reach that transaction, exactly one UPDATE touches a row, and the other rolls back and returns a clean 409. The outage raises my reject rate and latency, never correctness.
"The sweeper expires a hold at second 180 while the user checks out at 179.5. Who wins?"
Whoever commits first, and only one can. The checkout transaction re-reads the hold status and expires_at under the same transaction. If the sweeper already flipped it to expired, checkout fails cleanly and offers the seats back if still free. If checkout flips it to converted first, the sweeper's conditional update finds nothing to expire. Nobody ever pays for a reclaimed seat because the database transaction is the single referee.
"500k people, 60k seats. How do you keep the booking service from falling over?"
A virtual waiting room in front of the on-sale. It admits a few thousand shoppers at a time (say 5k) and issues each a signed token the gateway verifies on every reserve. 440k requests that were going to fail never touch inventory, so the reserve path sees thousands of QPS instead of 25k, and the Redis-plus-version scheme runs inside budget. Shaping demand before the lock is the single most important scaling call here.
"The payment provider hangs for 8 seconds. What happens to the seat?"
Nothing bad. I never hold a DB row lock across the payment call. The seat sits in held state, the order rides a queue, and a worker drives the provider asynchronously. If payment times out or fails, the hold expires on its normal 3-minute clock and the sweeper returns the seat to the pool. A slow provider degrades conversions, never the inventory path.
"A client retries a reserve on timeout. Does it grab two seat sets?"
No. Every mutating call carries an idempotency key, and the holds table enforces uniqueness per user key. The retry collapses to the first result and returns the same hold rather than grabbing and charging for a second seat set. Without this, aggressive flash-sale retries quietly produce phantom double-holds that eat inventory.

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.

Thanks. Check your inbox, your Design Ticketmaster prep plan is on the way.