5 min readRishi

Designing Ticketmaster: Booking, Inventory Holds, and Write Contention

Most systems shard their way out of load: spread users across partitions and every partition sees a trickle. A ticket on-sale is the opposite — the load is by definition concentrated on one event, often one section, sometimes one seat. 500,000 people arrive in the same minute wanting the same inventory, and the one unforgivable outcome is selling seat 14B twice. This is the write-contention interview, and it cannot be solved with "add a cache."

Requirements

Functional: browse events, view seat availability, hold seats while checking out, purchase, release expired holds. Non-functional: never double-sell (hard invariant), survive 100x load spikes at on-sale, checkout feels responsive, fairness — arriving first should mean something.

The state machine that anchors everything

Every seat for a given event is a tiny state machine:

AVAILABLE -> HELD (user U, expires_at T) -> SOLD
     ^            |
     +------------+   (hold expires or user abandons)

The hold with TTL is the design's centerpiece. Without holds, two users pay for the same seat and one refund-and-apology email goes out. With holds that never expire, abandoned carts strand inventory forever. Ten minutes, visible countdown, seat returns to the pool on expiry.

Never sell a seat twice

The invariant lives in the transactional store, not in application code. Two workable implementations:

Optimistic concurrency — fine for normal traffic:

UPDATE seats
SET status = 'HELD', user_id = :u, expires_at = now() + interval '10 min'
WHERE seat_id = :s AND event_id = :e
  AND (status = 'AVAILABLE'
       OR (status = 'HELD' AND expires_at < now()));
-- rows_affected = 0  ->  someone beat you; tell the user immediately

One statement, atomic, no lock held across user think-time. Expired holds are reclaimed lazily by the same predicate (plus a sweeper for hygiene) — no background job on the critical path.

Why not SELECT ... FOR UPDATE? Pessimistic locks held across a user's checkout think-time serialize the world; hundreds of waiters pile up on hot rows and the database melts precisely when you need it. Locks spanning human latency are the anti-pattern to name out loud.

At on-sale contention, though, optimistic retry also degrades — thousands of transactions fighting per row, ~all losing. The fix is not a cleverer lock; it is admission control.

The virtual waiting room

The real answer to 500K concurrent buyers is: don't let them all in. A virtual queue in front of the purchase flow converts an uncontrolled stampede into a controlled drain:

  • Users land in a queue (Redis sorted set by arrival time, or per-event Kafka partition); they get a position and a live estimate.
  • A gatekeeper admits N users per minute into checkout — N tuned to what the seat store sustains with low abort rates.
  • Admission grants a short-lived signed token; the purchase API rejects requests without one. (Otherwise scripters skip your queue via direct API calls.)

Fairness becomes explicit and auditable: arrival order, or a randomized lottery within arrival cohorts to blunt bot speed advantages. This is load shedding reframed as product: the queue page with an honest position number is the graceful degradation.

Browsing without touching the source of truth

The seat map that 500K people are refreshing must never hit the transactional store. Serve availability from a cache (Redis bitmap or seat-status hash per section) updated on state transitions — pub/sub or CDC from the seat store. It can be a few seconds stale; the truth is enforced at hold time anyway. "Seat looked free, hold failed" is a fine UX at scale; a molten primary is not. Cache the aggregate ("Section 112: 14 left") even more aggressively for the event page.

Payment sits after the hold, inside the hold's TTL, with an idempotency key per checkout attempt so a double-submitted card charge stays single. If payment webhooks outlive the hold (card review took 12 minutes), you need a policy: brief grace extension during in-flight payment, and compensation (refund) as the last resort — a small saga, and worth naming it as one.

Scaling the write path itself

Shard by event_id — events are independent worlds, so one on-sale's heat never touches another event. Within a mega-event, the row-level contention ceiling is real; the pressure valves, in escalating order: per-section sub-partitioning (spread hot sections across shards), the admission queue turned down, and for general-admission tickets — where seats are fungible — a counter-based allocation (decrement a section counter atomically, assign seat numbers post-purchase), which turns N contended rows into one atomic counter, the flash-sale trick.

Interview probeAnswer sketch
Double-sell prevention, precisely?Atomic conditional transition in the seat store; everything else is UX
Bots?Queue tokens + rate limits + proof-of-work/CAPTCHA at queue entry, not at purchase
Hold expiry mid-payment?Grace window keyed to payment intent; refund saga as fallback
Why not eventual consistency?The invariant is per-seat and financial; you cannot apologize your way out of two people in one seat

The shape to remember: cache the reads, queue the writers, and let one atomic statement own the invariant.

Keep reading

Newsletter

New posts, straight to your inbox

One email per post. No spam, no tracking pixels, unsubscribe anytime.

Comments

  • No comments yet. Be the first.