Blueprint
Geo & marketplaces intermediate

Design Ticketmaster

Bookings are a hundred writes a second — the entire difficulty is ten million people racing for fifty thousand seats without a single double-sell.

asked at Amazon · Uber · Stripe Sometimes asked ~35 min walkthrough
01

Scope the requirements

The trap is treating this as a scale problem. Write volume is tiny; the real problems are a vicious read spike on one event page and a race for the same few thousand rows. The interviewer is waiting for you to say that unprompted.

Functional — what it must do

  • Browse events and view a seat map with live availability.
  • Search events by keyword, date, location and performer.
  • Reserve specific seats for a limited window (~10 minutes) while paying.
  • Book tickets with a hard guarantee that no seat is ever sold twice.
  • Survive a flash on-sale fairly — a virtual waiting room for the Taylor Swift problem.
  • Explicitly cut: dynamic pricing, resale market, serious bot defence — offer them back as extensions.

Non-functional — the numbers that shape the design

  • Strong consistency on booking: a seat sells exactly once, even if a cache or node dies mid-purchase.
  • High availability for browse and search; read:write is roughly 100:1.
  • Search under 500 ms; seat-map freshness near-real-time during an on-sale.
  • One hot event can draw 10M+ concurrent users for ~50k seats — 200:1 oversubscription.
  • Bookings are low volume: ~10M tickets/day ≈ ~100 TPS — say this early, it reframes everything.
Say this out loud

I want to name the shape of this problem up front: writes are trivial, about a hundred bookings a second. This is not a throughput question — it's a contention question. An on-sale is millions of people racing for the same rows, and my job is to make that race safe and fair.

02

Back-of-envelope estimates

The arithmetic here exists to prove a point: every capacity number is small, so the design effort goes into concurrency control and absorbing one pathological read spike, not into sharding anything.

QuantityEstimateHow you got there
Bookings (writes)~100/s10M tickets/day ÷ 86,400 s ≈ 120; call it 100 average. One Postgres yawns at this.
Browse reads~10,000/s100:1 read ratio on bookings — and an on-sale concentrates most of it on one event page.
Hot-event contention200:110M viewers vs ~50k seats. This ratio, not QPS, is what the design must survive.
Ticket rows per event~50kOne row per seat; a stadium is a few MB. The contended resource is small enough to cache and lock per seat.
Storage per year≪ 1 TBEvents, venues, tickets, bookings are KB-scale rows. Storage is a non-issue; say so and move on.
Reservation hold10 min TTLThe product-defined payment window — it becomes the Redis lock TTL directly.
Say this out loud

A hundred writes a second and under a terabyte a year means one Postgres with replicas. So I'll spend the interview on the two things that are actually hard: the seat race and the on-sale stampede.

03

Sketch the API

Four endpoints. The interesting decision is splitting reserve from confirm — the hold is a UX promise with an expiry, the confirmation is the transaction.

GET/events/{eventId}→ event + venue + performer + seat availability. The page 10M people refresh — served almost entirely from cache.
GET/events/search?keyword=&date=&location=Full-text and filtered search, paginated. Backed by Elasticsearch, not the transactional store.
POST/bookings/{eventId}body: { ticketIds[] } → { bookingId, expiresAt }. Places a timed hold on specific seats. userId from the session token, never the body.
PUT/bookings/{bookingId}/confirmFinalises after payment succeeds — in practice driven by the payment provider's webhook, not the client.
  • Reserve and confirm are separate calls deliberately. The hold gives the user a calm 10 minutes to pay; the confirm is where correctness lives. Collapsing them means either holding a database transaction open for minutes or double-selling on payment retries.
  • Nothing money-shaped comes from the client: price is looked up server-side, userId comes from the session, and the ticketIds are re-validated at confirm time. A replayed or tampered request buys nothing.
04

Data model

Seat-level ticket rows with a small state machine are the heart of it: available → reserved → booked, with the last transition guarded by a conditional update.

events / venues / performers
id PK · name · venue_id · date · …
Read-mostly reference data; cached hard, CDC'd into search.
tickets
id PK · event_id · seat · price · status · user_id?
One row per seat — the contended resource. status transitions only via conditional UPDATE; there is no code path that sets booked unconditionally.
bookings
id PK · user_id · ticket_ids · status · created_at
The purchase record; links to the payment provider's intent id for reconciliation.
The database call

I'd put tickets, bookings and events in one Postgres, and I'd say out loud that I'm breaking the database-per-service reflex on purpose: this data is tightly coupled and the no-double-sell guarantee wants a single ACID transaction, not a distributed one. Search peels off into Elasticsearch because that's genuinely a different access pattern. I'd only shard Postgres — by event_id — if a single event's write volume outgrew a box, and at 100 TPS that day never comes; reads break first, and caching absorbs reads.

05

High-level design

Three planes: a browse path that is nearly all cache, a booking path where Redis grants holds and Postgres has the final word, and a waiting queue that meters admission during on-sales so the stampede never reaches the database.

client edge service data async Fan API gateway auth, rate limit Search service Booking service holds + OCC Elasticsearch event search Postgres tickets, bookings Redis locks + queue Kafka CDC Debezium Stripe payments query reindex SET NX EX 600 OCC update CDC POST /bookings charge webhook search
notice the layering: redis grants holds and admits users from the waiting queue, but the conditional update in postgres is the only thing that makes a sale final.
  1. Browse: the fan loads the event page → served from Redis/CDN cache; during a hot on-sale the seat map streams availability deltas over SSE instead of being polled.
  2. On-sale admission: fans land in a Redis sorted set waiting queue (score = arrival time or a random draw); a scheduler admits a batch at a time into an admitted:{eventId} set with a TTL. The booking service rejects reservation calls from anyone not in it.
  3. Reserve: an admitted fan POSTs ticketIds → booking service takes per-seat Redis locks (SET ticketId userId NX EX 600) → returns the hold with expiresAt. Seats show as unavailable to everyone else instantly.
  4. Pay: the client completes payment with Stripe; the webhook — not the client — tells the booking service the money cleared.
  5. Book: the service runs UPDATE tickets SET status='booked', user_id=? WHERE id IN (...) AND status != 'booked' in one transaction and checks the row count. This conditional update is what makes the system correct even if Redis lied.
06

Deep dives — where the interview is won

Phase 01

Seat holds live in Redis; correctness lives in Postgres

There's a bad, a good and a great answer here. Bad: hold a database transaction or row lock open for the 10-minute payment window — connections pile up and one slow payer stalls the table. Good: a status='reserved', expires_at=... column with a cron sweeper — correct, but seats stay dark for minutes after a hold lapses. Great: the hold is a Redis lock with a TTL (SET ticketId userId NX EX 600) — atomic grant, automatic expiry, sub-millisecond, and the database never knows holds exist.

The move interviewers reward is refusing to let Redis be load-bearing for correctness. At confirm time the booking service runs a conditional updateUPDATE ... SET status='booked' WHERE id=? AND status != 'booked' — and treats a zero row count as failure. Now the failure analysis is clean: if Redis loses every lock mid-on-sale, two users may both reach checkout believing they hold seat 14B, but exactly one update wins. The other gets an apology, not a duplicate ticket.

user hold final Buyer Redis hold TTL 5 min Postgres commit SETNX seat on pay
hold is a redis key with TTL. purchase moves the seat_id row in postgres inside a transaction.
Trade-offRedis failure degrades UX — holds vanish and some users fail at the final step — but never degrades correctness; that separation of concerns is the entire point of the layered design.
Phase 02

A virtual waiting queue turns a stampede into a drip

When 10M people hit one on-sale, no amount of autoscaling makes it sensible to let them all race the booking path at once — you'd melt the seat-lock layer to sell 50k tickets. So you don't. Before the on-sale opens, fans connect over SSE or WebSocket and are placed in a Redis sorted set; a scheduler dequeues a controlled batch — say a few thousand — into an admitted set with a TTL, sized to what the booking path comfortably handles. Everyone else watches an honest position counter instead of a spinning error page.

Enforcement has to sit server-side: the booking service checks membership of admitted:{eventId} and rejects reservation calls from anyone else, so bypassing the queue UI buys nothing. Scoring is a product decision worth naming: arrival-time ordering feels fair but rewards refresh-bots hammering the connect endpoint; a random draw within a window is the fairer choice under bot pressure, and it's what large ticketers actually run.

rush gate buy 10M users Virtual queue cohort every 30s Purchase svc released cohort
waiting room releases users in cohorts. gates set by capacity of the buy path.
Trade-offThe queue deliberately trades latency for survival and fairness — real fans wait longer than raw capacity strictly requires, and the admission scheduler becomes a component you must monitor and tune per event.
Phase 03

The seat map: push deltas, never let ten million clients poll

A naive seat map polls GET /events/{id} every few seconds. Multiply by 10M viewers and you've built a self-inflicted DDoS worse than the booking race. The fix is to invert it: viewers of a hot event hold an SSE connection and the booking service publishes seat-state deltas — reserved, released, booked — through pub/sub to the connection tier. Each state change is one tiny message fanned out, instead of millions of full-page reads.

For the long tail of ordinary events, polling a cached endpoint every 30 seconds is fine and SSE is over-engineering — say that, the discrimination is worth points. And name the degraded mode: under extreme load you drop real-time updates and show "availability may be stale, confirmed at checkout", which is honest because the conditional update at confirm re-validates anyway.

svc ws clients Seat state WS gateway Clients × 10M delta patch
server pushes deltas over websocket; clients apply patches, never fetch full seatmap.
Trade-offSSE fan-out means a stateful connection tier to scale and drain on deploys — you accept that operational weight only for the handful of events that need it.
Phase 04

Search is Elasticsearch fed by CDC, not LIKE queries

Keyword search with WHERE name LIKE '%beyonce%' can't use a B-tree index — it's a sequential scan and it fails the 500 ms budget the moment the catalogue grows. The workmanlike answer is Postgres full-text search (tsvector + GIN index) — genuinely fine at this catalogue size. The stronger answer is Elasticsearch: an inverted index with typo-tolerant fuzzy matching, faceting by date and location and performer, and relevance ranking product will inevitably ask for.

The part candidates skip is how Elasticsearch stays fresh: change data capture — Debezium tails the Postgres WAL and streams row changes through Kafka into the index. No dual-writes from application code, because dual-writes drift the moment one side fails. Seconds of staleness is fine for search; anything booking-critical re-reads Postgres.

postgres cdc index events table Debezium Elasticsearch WAL index
cdc from postgres feeds elasticsearch. text search never hits the transactional store.
Trade-offElasticsearch adds a whole subsystem (cluster, CDC pipeline, reindex playbook) to beat GIN indexes on fuzziness and ranking — at small catalogue scale, Postgres full-text is the more defensible call.
07

Follow-ups you should expect

What happens if payment takes longer than the 10-minute hold?
When the payment intent is created I extend the Redis lock TTL — the user has demonstrably entered checkout, so the hold follows the payment's lifetime. If payment ultimately fails or the extension lapses, the lock expires and seats return to the pool. And if a webhook lands after expiry, the conditional update either still wins the seats or fails cleanly and triggers a refund — correctness never depends on the timer.
Redis dies in the middle of an on-sale — what breaks?
Holds and the waiting queue vanish, so UX degrades: seat-map holds disappear and more users fail at checkout. Correctness survives untouched, because the conditional update in Postgres is the arbiter of who gets each seat. I'd degrade to a no-holds mode — pick seats, race at confirm — and rate-limit at the gateway to protect the database until Redis returns.
Why pessimistic holds at all? Why not just let everyone race at checkout?
For assigned seating, holds are the product: nobody enters card details for seats that may evaporate, and abandonment would be brutal. For general-admission tickets there are no specific seats to contend, so I'd skip holds entirely and decrement a counter atomically at purchase. Match the concurrency model to the inventory model.
FIFO queue or lottery for the waiting room?
FIFO feels fairer but rewards whoever hammers the connect endpoint fastest — which under bot pressure means bots. A random draw within an arrival window resists that and spreads the load pattern. I'd run the lottery and publish the mechanism, because perceived fairness matters as much as actual fairness here.
How does Elasticsearch stay consistent with Postgres?
It deliberately doesn't — it's eventually consistent via CDC (Debezium tailing the WAL into Kafka), typically seconds behind. That's fine because search results are a discovery surface, not a source of truth: the booking path always re-validates against Postgres. I'd avoid dual-writes from the app, since they silently drift when one write fails.

Where candidates lose the room

  • Holding a database transaction or row lock open for the 10-minute payment window — the moment the interviewer asks "what happens with a thousand concurrent holds?" the design collapses.
  • Making the Redis lock the only defence against double-selling — no conditional update at confirm means one cache hiccup sells seat 14B twice, and it's the exact failure interviewers probe.
  • Sizing the system for booking QPS and never mentioning the on-sale stampede — it misses the entire point of the question.
  • LIKE '%term%' search with no full-text index and no story for keeping the search index fresh.
  • Splitting tickets and bookings into separate services with separate databases out of microservice dogma, then inventing a distributed transaction to reunite them.

Concepts this leans on

Found a bug or want more?

Tell me what's broken, missing, or wrong. Every message lands in my inbox.