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.
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.
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.
| Quantity | Estimate | How you got there |
|---|---|---|
| Bookings (writes) | ~100/s | 10M tickets/day ÷ 86,400 s ≈ 120; call it 100 average. One Postgres yawns at this. |
| Browse reads | ~10,000/s | 100:1 read ratio on bookings — and an on-sale concentrates most of it on one event page. |
| Hot-event contention | 200:1 | 10M viewers vs ~50k seats. This ratio, not QPS, is what the design must survive. |
| Ticket rows per event | ~50k | One 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 TB | Events, venues, tickets, bookings are KB-scale rows. Storage is a non-issue; say so and move on. |
| Reservation hold | 10 min TTL | The product-defined payment window — it becomes the Redis lock TTL directly. |
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.
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.
- 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.
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.
status transitions only via conditional UPDATE; there is no code path that sets booked unconditionally.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.
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.
- 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.
- 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. - Reserve: an admitted fan POSTs ticketIds → booking service takes per-seat Redis locks (
SET ticketId userId NX EX 600) → returns the hold withexpiresAt. Seats show as unavailable to everyone else instantly. - Pay: the client completes payment with Stripe; the webhook — not the client — tells the booking service the money cleared.
- 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.
Deep dives — where the interview is won
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 update — UPDATE ... 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.
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.
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.
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.
Follow-ups you should expect
What happens if payment takes longer than the 10-minute hold?
Redis dies in the middle of an on-sale — what breaks?
Why pessimistic holds at all? Why not just let everyone race at checkout?
FIFO queue or lottery for the waiting room?
How does Elasticsearch stay consistent with Postgres?
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.