Designing Ticketmaster
A ticket-booking platform for concerts and live events. The heart of the problem is preventing two people from buying the same seat — while staying fast and available for the millions who are only browsing.
Ticketmaster forces a split decision on CAP. Viewing and searching events must be highly available — a few seconds of staleness is harmless. Booking must be strongly consistent — no seat can go to two people. The whole design is one path optimized for read scale and another for write correctness, joined by a temporary reservation mechanism.
1Understanding the Problem
An online platform to buy tickets for concerts, sports, theater, and other live entertainment.
Functional requirements
Prioritize the top three "users should be able to…" statements; note the rest below the line. The video derives them by walking the user flow backwards: to book you must view an event; to view it you must first search for it.
- Users can view events (details + seat map).
- Users can search for events.
- Users can book tickets to events.
Below the line: viewing one's booked events, admin/event-coordinator flows (adding events), and dynamic pricing for popular events.
Non-functional requirements — the interesting part
Don't just write "scalability, availability, reliability." Every system has those. The point of non-functional requirements is to identify what makes this system unique and challenging — state each quality in the context of Ticketmaster.
1. CAP split: availability for search/view; consistency for booking (no double booking).
2. Scale for popular-event surges: ~10M users hammering one event (Taylor Swift, Super Bowl, World Cup).
3. Low-latency search: < 500 ms.
4. Read-heavy: reads ≫ writes, ~100:1.
A mid-level candidate says "prioritize consistency" (no double booking) and stops. A more senior answer recognizes both can coexist in different parts of the system: strong consistency for booking, high availability for search and viewing. If someone in Germany buys a seat I'm looking at, I must know instantly (or get an error) — but a newly-added event taking a few seconds to appear in search is fine.
If the booking conversion rate is ~1%, far more people search and view than actually buy — so reads dominate writes roughly 100:1. This directly justifies caching and read replicas later. (Out of scope: GDPR, fault tolerance, secure transactions, CI/CD, backups.)
After listing above/below-the-line items, ask: "Here's what I'll prioritize and what I've put out of scope — want me to move anything up or down?" It shows product thinking while keeping you focused.
2Core Entities & the API
Approach: build up sequentially through the functional requirements; then let the non-functional requirements drive the deep dives. Start with the entities (the nouns / persisted data), then the API.
| Entity | Role |
|---|---|
| Event | Central info: date, description, type; FKs to venue & performer; one-to-many to Tickets. |
| Venue | Physical location: address, capacity, and the seat map (e.g. JSON of sections/rows/seats + coordinates). |
| Performer | The artist/team/collective (name, description). |
| Ticket | One per seat: event ID, seat details, price, status. Generated for every seat when an event is created; combined with the seat map to render the UI. |
| Booking | A purchase: user ID, list of ticket IDs, total price, status (in-progress/confirmed). |
| User | The person interacting with the system. |
You could fold booking data into Ticket, but a separate Booking groups multiple tickets bought in one transaction under a single order with a shared payment status and total price.
2.1 · Booking is a two-phase process
Booking isn't one step. Like airline seats: you reserve a seat, get a ~10-minute countdown, then confirm by paying. If you don't confirm, the seat returns to available. So a single booking endpoint should evolve into two: reserve, then confirm.
// View — returns everything to render the event page + seat map GET /events/:eventId -> Event & Venue & Performer & Ticket[] // Search — all optional filters GET /events/search?keyword&start&end&pageSize&page -> Event[] // Book — simple to start... POST /bookings/:eventId { ticketIds: string[], paymentDetails } -> bookingId // ...evolves into two phases: PUT /bookings/reserve { ticketId } // reserve for 10 min PATCH /bookings/confirm { ticketId, paymentDetails }
A client-supplied userId can be forged — you could reserve on someone else's behalf if you knew their ID. Carry the user identity in the header (JWT or session token) and validate server-side. Noting this shows technical care without costing much time.
It's fine to start simple: "Here's a basic API; as the design develops we'll likely split booking into reserve + confirm." Communicating the evolution is the point.
3High-Level Design
Microservices behind an API Gateway (routing + auth + rate limiting). Walk the three requirements in order.
3.1 · View, search, book
View. The Event Service reads the event, venue, performer (a join) plus the event's tickets (to render the seat map) and returns them.
Search. The simplest possible version is a SQL LIKE query — which the instructor flags immediately as slow (full table scan) and defers to the search deep dive.
-- works, but sucks: wildcard forces a full table scan SELECT * FROM Events WHERE name LIKE '%Taylor%' OR description LIKE '%Taylor%'
Book. Needs a transactional DB. The write-up picks PostgreSQL (any ACID store — MySQL, DynamoDB — would do) and will add isolation + row locking / OCC to prevent double bookings.
What matters isn't the label but the qualities you need: ACID transactions on tickets, some relationships. Postgres fits. A senior candidate says "here are the properties I need; either works" and moves on — modern NoSQL can also do ACID. Fighting the SQL/NoSQL war reads as junior.
3.2 · Why one shared database (not one per service)
A shared DB is right here because the data is tightly coupled (bookings need tickets need events), booking needs ACID transactions, and splitting adds complexity for no benefit. In the interview, weigh the tradeoff out loud rather than parroting dogma. (A commenter notes real Ticketmaster appears to separate event metadata from seat/booking data, since their access patterns differ — a valid larger-org direction.)
A user can reach the payment page, type in card details, and only then discover the ticket was taken by someone faster. Terrible UX. Fixing this is the first — and most important — deep dive.
4Deep Dive · Reserving Tickets (the crux)
We need to (a) lock a ticket while the user checks out, (b) release it automatically if they abandon, and (c) mark it sold on success. Four approaches, worst to best.
4.1 · The solution ladder
BadLong-running database locks (SELECT FOR UPDATE)
Lock the ticket row inside an interactive transaction held open for the whole ~5-minute checkout.
DB locks are meant for near-instant transactions. Holding one for minutes strains DB resources, invites lock contention and deadlocks, and scales poorly under load. Crashes or network blips can leave locks in an uncertain state. (Postgres lock_timeout exists but just errors the user rather than queuing gracefully.)
GoodStatus + expiration timestamp, released by cron
Give each ticket a status — available / reserved / booked — and a reserved_timestamp. On reserve, flip to "reserved" and record the time. A cron job periodically flips expired "reserved" rows back to "available."
There's an inherent lag between expiry and the sweep. If a ticket should free up at 12:00 but the job next runs at 12:09, it stayed locked for 19 minutes, not 10 — bad for hot events. If the cron fails, tickets can look unavailable indefinitely. Passing for mid-level, not for senior+.
GreatImplicit status via short transactions
Recognize a ticket's true status is "available OR (reserved but expired)." Use short transactions (not long locks) that check-then-set:
BEGIN; -- reserve only if AVAILABLE or (RESERVED and expiration < now) UPDATE tickets SET status='reserved', expiration = now() + interval '10 min' WHERE id = ? AND (status='available' OR expiration < now()); COMMIT;
This guarantees only one user reserves a ticket and lets users claim expired reservations instantly — no cron in the critical path. Cost: reads must filter on two fields (partly solved by a compound index / materialized view), and the table is less legible. A sweep job can still tidy stale rows, but crucially the system stays correct even if that sweep is delayed.
GreatDistributed lock with TTL (Redis)
Because we need a temporary reservation that auto-expires. Postgres has no native row-level TTL — you'd need the cron above. Redis gives built-in key expiration, and being in-memory, lock acquire/release is extremely fast under high concurrency.
On reserve, set a Redis key ticketId → userId with a 10-minute TTL. On confirm, update the DB to "booked" and release the key. If the user vanishes, the TTL expires the key automatically and the seat frees itself. The Ticket table now needs only two states — available and booked — with reservations living entirely in Redis.
# Atomic acquire — only one client wins the key
SET ticket:{id} {userId} NX EX 600
The Booking Service horizontally scales to many instances; they all need one consistent view of what's locked. An in-memory lock on a single instance wouldn't be seen by the others — hence a shared Redis. Multi-seat bookings acquire locks per ticket (release the ones you got if any fail); a Lua script can make multi-lock atomic when keys hash to the same node.
Read-path complexity: the seat map must show reserved seats as unavailable. A plain Redis Set leaks "ghost" seats (members don't expire with the lock keys). Use a sorted set scored by expiry — ZADD event:{id}:reserved <expiresAt> ticketId — and only count entries with a future score, trimming stale ones with ZREMRANGEBYSCORE. Alternatively write-through a "reserved" status to the DB.
If the lock goes down: bring up a new instance; for a ~10-min window several users could reach payment for the same seat. You never double-book (the DB's OCC / row locking guarantees one winner) — the losers just get an error. Better than the cron-failure case where all seats look unavailable.
TTL expiring mid-payment: if A's lock expires at min 10 but their payment lands at min 11 after B grabbed the lock, the DB transaction fails for one of them (OCC) and you auto-refund via Stripe. Set a generous TTL, and consider extending the lock when payment starts.
Commenters debate making the lock highly available (Redlock, Redis Sentinel/Cluster, or consensus stores like etcd/Consul). Evan King's take: you're right in theory, "but it should not matter" — the DB still guarantees no double booking, so a brief lock outage only degrades UX. No interviewer expects Redlock, but mentioning it can impress.
4.2 · The full booking flow (with Stripe)
- User selects a seat →
POST /bookingswith the ticketId. - Booking Service locks the ticket in Redis (TTL 10 min) and writes a booking row with status in-progress; returns a
bookingIdand routes to the payment page. (Abandon here → lock auto-releases in 10 min.) - Client tokenizes card details with Stripe.js (server never sees raw card numbers — PCI compliance) and sends the token + bookingId; server creates a Stripe PaymentIntent.
- Stripe processes asynchronously and calls back via webhook on success.
- The webhook runs a DB transaction: ticket → sold, booking → confirmed.
Stripe retries webhooks on failure, so processing the same event twice must not double-apply. Use the bookingId as an idempotency key and check the current booking status before updating.
5Deep Dive · Scaling the View Path to Millions
When tickets drop, thousands refresh the same event page. Handle it with caching + load balancing + horizontal scaling.
Event details, performer bios, and static venue info have high read rates and low update frequency — cache them aggressively (Redis/Memcached) as eventId → eventObject via read-through. Invalidate with DB triggers on change and use TTLs — long for static venue data, short for volatile availability. The Event Service is stateless, so scale it horizontally behind a load balancer (Round Robin / Least Connections).
Because event/venue/performer almost never change, cache the eventId → {event, venue, performer} object and you may not even need an eviction policy (bound it to upcoming/recent events). Only the tickets query stays dynamic and uncached. That makes the view call wicked fast while keeping availability correct.
The instructor deliberately skipped back-of-envelope math after requirements. Doing QPS/DAU math just to conclude "it's big" teaches no one anything — "math without a purpose." Calculate only when the result changes your design — e.g. computing storage to decide whether to shard (and if so, on eventId for query locality, or venueId for geographic distribution).
6Deep Dive · Keeping the Seat Map Live
After the page loads, the seat map goes stale — users click seats that are already gone and get errors. We need real-time updates.
GoodPush updates — long-polling or SSE
The write-up reaches for Server-Sent Events (SSE): a one-way, persistent server→client channel that pushes "seat taken" as soon as it happens.
Start with long-polling (a request held open ~30–60s, looped) — cheapest, no new infra, perfect if users sit on the page only a minute or two. If analytics show users linger 10–30+ minutes, upgrade to a persistent connection. Prefer SSE over WebSockets here: we only need server→client, and SSE is unidirectional (WebSockets are bidirectional and heavier than needed).
6.1 · GreatVirtual waiting queue for mega-events
For a Taylor Swift / Super Bowl drop, the seat map loads and "goes black" in milliseconds as 100k–1M people fight over the same seats — a disorienting, overwhelming experience even with live updates.
Introduce a choke point: an admin-enabled virtual waiting queue in front of the booking page (ideally in front of viewing the seat map). Users get "you're in the queue; we'll let you know." Back it with a Redis sorted set (by arrival time, or randomized for fairness). Dequeue in batches — e.g. admit the next 100 as seats book — mark them "admitted" (session in an admitted:{eventId} set with a TTL), and the Booking Service rejects anyone not admitted. Notify admitted users over their SSE/WebSocket connection.
The best answer often isn't the most technically complex. A queue is simple yet solves overload elegantly — the mark of a senior/staff engineer is solving the business problem creatively, sometimes by thinking outside the presumed constraints. (A well-received comment and Evan agree the queue should gate the seat-map view, not just the booking service.)
7Deep Dive · Fast Search
The naive LIKE '%...%' forces a full table scan. Improve it in stages.
GoodIndexes & query optimization
Index the columns used in search (event name, date, performer, venue location); use EXPLAIN, avoid SELECT *, use LIMIT, prefer UNION over OR. Limit: standard indexes are weak for partial-string matches like "Taylor" inside "Taylor Swift," and indexes slow writes.
GreatFull-text indexes in the database
Postgres has built-in full-text search (tsvector + GIN indexes); MySQL has its own. These beat LIKE scans for term matches. (Neither uses Lucene — that's Elasticsearch.) Cost: more storage, harder to maintain.
GreatElasticsearch + CDC
Elasticsearch tokenizes event text ("Philadelphia Eagles playoff…") into terms and maps each term → the events it appears in. Searching "playoff" becomes a direct lookup. It also supports geospatial queries (quadtrees/geohashing) and fuzzy search for typo tolerance ("Tayler Swift" → "Taylor Swift").
Keep Postgres as the source of truth and sync changes into Elasticsearch. Two ways: write to both in application code (handle partial-write failures/retries), or use Change Data Capture (CDC) — stream DB changes to a consumer that updates Elasticsearch.
"We used Elasticsearch as a primary data store at my first startup and learned the hard way it didn't work" — durability concerns, no complex transactions. Also, Elasticsearch has a write ceiling because each write updates indexes; high write volume needs batching/a queue. But events/venues/performers barely change and are rarely added (tens–thousands/day), so a simple per-change update to Elasticsearch is fine — no queue needed here.
Speeding up repeated queries
For popular searches ("Taylor Swift"), cache results — but only because this system does no personalization (everyone gets the same results for the same query). Options, from the video:
- Elasticsearch / OpenSearch node query cache — caches the top ~10K queries per shard (LRU); often just a config flag.
- Redis/Memcached on a normalized search term → results, with invalidation on updates.
- CDN caching the search API response for ~30–60s — wicked fast for hot terms and geographically close.
The more query params (precise lat/long, dates, types), the fewer two users share the exact same query, so hit rates fall and you waste CDN space. Coarse locations (e.g. "San Francisco") cache well; raw lat/long doesn't. And if the system ever personalizes results, shared caching breaks entirely.
8What's Expected at Each Level
| Level | Focus | The bar for Ticketmaster |
|---|---|---|
| Mid (E4) | ~80% breadth | Clear APIs + data model; functional view & book. Solve double-booking with at least the Good status+timeout+cron solution. Deeper dives are a bonus, not expected. |
| Senior (E5) | ~60/40 | Speed through the HLD; then go deep: Elasticsearch for search, a distributed lock (or comparable) for reservations, and a real discussion of handling popular events, sharding, replication. |
| Staff+ | ~40/60 | Breeze past basics; dive deep into 2–3 areas with proactive, experience-backed insight. Interviewer intervenes only to focus, not steer. Ideal: the interviewer learns something. |
Your interviewer is busy — they'll write feedback the next day from your diagram alone. Clear visual communication directly improves your outcome. Finish by re-checking every functional and non-functional requirement against the final design.
★Active Recall
Why does Ticketmaster split its CAP decision, and how?
Different parts have different needs. Search/view can tolerate seconds of staleness → prioritize availability. Booking must never assign one seat to two people → prioritize consistency. The naive "just pick consistency" is the mid-level answer; recognizing the split is more senior. (§1)
Why is a long-running SELECT FOR UPDATE lock a bad way to reserve a ticket?
DB locks are designed for near-instant transactions. Holding one for a multi-minute checkout strains DB resources, causes lock contention and deadlocks, scales poorly, and leaves locks in limbo on crashes. (§4.1)
What's the flaw in the status + cron approach, and how do the two "great" solutions each fix it?
The cron sweep lags expiry (Δ), so a ticket can stay locked past its 10 minutes, and a cron failure can hide seats. Implicit status: treat "reserved-but-expired" as available inside a short check-then-set transaction — correct even if a cleanup sweep is delayed. Redis TTL lock: the key auto-expires exactly on time, freeing the seat with no cron in the path. (§4.1)
If Postgres already guarantees consistency, why add a Redis lock at all?
We need a temporary reservation that auto-expires. Postgres has no native row TTL (you'd need the cron). Redis has built-in key expiration and is in-memory, so lock acquire/release is fast under high concurrency. It's distributed because many Booking Service instances need one shared view of what's locked. (§4.1)
What happens if the Redis lock goes down — do we double-book?
No. Bring up a new instance; for ~10 min several users might reach payment for the same seat, but the DB's OCC / row locking still ensures one winner — the others just get an error. That's a better failure mode than the cron approach failing and making all seats look unavailable. (§4.1)
How do you show reserved seats on the map when reservations live in Redis (not the DB)?
Don't use a plain Redis Set — members don't expire with the lock keys, leaving ghost seats. Use a sorted set scored by expiry time (ZADD with expiresAt); count only future-scored entries and trim stale ones with ZREMRANGEBYSCORE. Or write-through a "reserved" status to the DB. (§4.1)
Why must the Stripe webhook be idempotent, and how?
Stripe retries webhooks on failure, so the same success event may arrive twice. Use the bookingId as an idempotency key and check current booking status before updating, so a repeat doesn't double-apply state changes. (§4.2)
SSE vs long-polling vs WebSockets for the live seat map — how to choose?
Start with long-polling (cheap, no infra) if users stay only a minute or two. If analytics show long sessions, use a persistent connection. Prefer SSE over WebSockets since updates only flow server→client (SSE is unidirectional; WebSockets bidirectional and heavier than needed). (§6)
When and why introduce a virtual waiting queue?
For mega-events where the seat map would instantly go black. It's an admin-enabled choke point (Redis sorted set) in front of the booking/seat-map view that admits users in batches, protecting the backend and improving UX. Simple yet sophisticated — a senior/staff signal. (§6.1)
Why not make Elasticsearch the primary datastore, and how is it kept in sync?
Durability concerns and no complex transactions (instructor learned this the hard way). Keep Postgres as source of truth; sync via CDC or dual-writes. Because events change rarely, per-change updates suffice — no queue needed. Fuzzy search handles typos. (§7)
When is caching search results in a CDN a poor fit?
When queries have many permutations (precise lat/long, dates, types), two users rarely share an exact query so hit rates fall and CDN space is wasted; coarse locations cache better. Also breaks if results become personalized, since shared caching assumes identical results per query. (§7)
↺Quick Revision
Must remember
- CAP split: consistency for booking, availability for view/search.
- Read:write ≈ 100:1 (~1% conversion).
- Booking is two-phase: reserve → confirm.
- Search target < 500 ms; scale for popular-event surges.
- User identity in the header, never the body.
Reservation lock ladder
- Bad: long
SELECT FOR UPDATE. - Good: status + timestamp + cron (has Δ lag).
- Great: implicit status (short check-then-set txn).
- Great: Redis key + TTL (
SET NX EX).
Rules & principles
- DB locks = short only; reservations = TTL, not held txns.
- Webhook must be idempotent (bookingId key).
- DB (OCC/row lock) is the true double-booking guard; Redis is UX.
- Math only if it changes a decision.
Common traps
- Writing bare "-ilities" as non-functional reqs.
- Fighting SQL vs NoSQL.
- Plain Redis Set → ghost reserved seats (use sorted set).
- Elasticsearch as primary store.
- CDN-caching highly-parameterized / personalized search.
Deep-dive map (non-functional → solution)
- Consistency (booking): two-phase reserve/confirm + Redis TTL lock, DB OCC as backstop, idempotent Stripe webhook, auto-refund on TTL-vs-payment race.
- Availability (views): cache event/venue/performer hard (not tickets) + read-through + horizontal scale + optional sharding.
- Popular events: live seat map via long-poll/SSE, then a virtual waiting queue choke point.
- Low-latency search: full-text indexes → Elasticsearch + CDC + fuzzy → node/Redis/CDN caching.
Study notes compiled from the Hello Interview "Design Ticketmaster" write-up and its video walkthrough by Evan King (ex-Meta staff engineer). Instructor commentary is drawn from the video; community footnotes (Redlock/HA, queue placement, per-service DBs) come from the page's comment thread and are flagged as discussion, not settled answers.