Ticketmaster
0-day streakReview

Your progress

Level 1

0 XP100 XP to level 2
0day streak
0best streak
0topics started
0cards reviewed
    Review cards

    Progress is stored only in this browser. No account is required.

    Layer 4 · Full breakdown · 34 min · 13 figures

    Design Ticketmaster (BookMyShow)

    Design a ticketing platform like Ticketmaster or BookMyShow: event search, live seat maps, seat holds that never double-book, a virtual waiting room for hot on-sales, bot defences and late-payment edge cases.

    A ticketing platform sells something unusual: every item exists exactly once. Seat 14 in row K of block C is not interchangeable with seat 15, and once sold to one fan it must never be sold to another. Most days the platform is a quiet, read-heavy catalogue — people check Saturday’s movie timings on BookMyShow and leave. Then a stadium tour goes on sale at noon, and millions of fans want the same few thousand seats in the same few minutes. When Ticketmaster opened sales for Taylor Swift’s Eras Tour in November 2022, it reported 3.5 billion system requests, four times its previous peak, and still sold over 2 million tickets that day. You will design that platform: event search, live seat maps, seat holds that expire, payment and confirmation, and the waiting room that keeps a hot on-sale fair and the system standing.

    Understand the problem

    First pin down the inventory. Assigned seating — a cinema seat, a numbered stadium seat — makes every seat a distinct record that two fans can collide on. General admission — a standing pit for 5,000 — is just a counter, the fungible-stock problem of design a flash sale. This design is about assigned seats, with general admission as an aside.

    Functional requirements

    1. Users should be able to search and browse events by city, date, artist, team, venue or genre — “Coldplay in Ahmedabad”, “Hindi movies near me tonight”.
    2. Users should be able to view an event and its seat map with live availability, section by section, with prices.
    3. Users should be able to hold specific seats for a few minutes — up to a per-account limit, say six — while they check out. Nobody else can take those seats during the hold.
    4. Users should be able to pay and receive a confirmed e-ticket. If they don’t pay in time, the seats go back on sale.

    A virtual waiting room is deliberately missing: fans don’t ask for a queue; it is how you meet the scale and fairness requirements, so it lives in the deep dives.

    Non-functional requirements

    • Never sell a seat twice. Each seat’s state is strongly consistent: once a hold or sale is acknowledged, every later attempt on that seat sees it. The acceptable error rate is zero.
    • Browse and search stay up. 99.99% for search, event pages and seat maps, where data a few seconds old is fine. Booking may slow down or queue under load, but never trades correctness for availability: in CAP terms, reads are AP and the seat is CP.
    • Read-heavy and fast. About 90 reads per write on an ordinary day (derived below). Search p99 under 300 ms, the seat map drawn in under a second, a hold answered in under 200 ms at p99.
    • Spiky, but predictably so. A hot on-sale brings about 2 million fans to one event within minutes — about 70× the normal peak — at a time announced days ahead.
    • Holds expire reliably. Ten minutes for a concert (a cinema might use five to seven); a released seat reappears for others within seconds.
    • Fair. Arriving at 11:59:59 should not beat arriving at 11:55, and a bot farm should not beat a fan.
    • Durable bookings. Zero data loss (RPO 0) for bookings and payments if any single machine fails.

    In the interview

    Ask two questions before you draw: “Assigned seats or general admission?” and “How hot is the hottest on-sale — a cinema chain’s Friday release or a stadium tour with millions waiting?” The first decides whether inventory is rows or a counter. The second decides whether you need a waiting room at all.

    Out of scope

    • Payment internals. Cards, UPI, payment service providers (PSPs) and reconciliation belong to design a payment system; here you only care how a payment interacts with a hold.
    • Organiser tools. Creating events, uploading layouts and setting prices is low-traffic CRUD.
    • Dynamic pricing and resale. Touched on in the follow-ups.
    • Gate entry. Scanning tickets at the venue is a separate, offline-capable system.
    • Recommendations and reminders. Other systems, such as a notification system.

    The set-up

    Plan the approach

    Two systems share one product. The browse side — search, event pages, seat maps — is a read-heavy catalogue that can be cached everywhere and a few seconds stale. The booking side — holds, payment, confirmation — handles few writes, each of which must be exactly right. Meet each requirement simply, then spend the deep dives on what is hard: double booking, the on-sale crowd, live seat maps, search, the one hot event, bots and late payments.

    Core entities

    • Venue and seat layout. Sections, rows and seats with drawing coordinates. Versioned and shared by every event at that venue.
    • Event. One show: performer or film, venue, start time, on-sale time, layout version, price tier per section.
    • Event seat. One row per seat per event — (event_id, seat_id) with section, price tier, status, current hold, hold expiry and booking. This is the inventory the whole design protects: 60,000 rows for a stadium show, 1,000 a day for a 200-seat screen with five shows.
    • Hold. A short-lived claim by one user on one to six event seats, with an expiry.
    • Booking. A hold that went to checkout: user, seats, amount in ₹, payment reference, status.
    • Ticket. One per seat in a confirmed booking, with the QR code the gate scans.

    Keep the layout and the event seats apart: the layout is big and static, the status tiny and changing every second. That split shapes the seat-map design.

    API

    Browsing is plain, cacheable GETs. The layout URL contains its version, so it can be cached for days.

    GET /v1/events?q=coldplay&city=ahmedabad&from=2026-01-01&to=2026-01-31&cursor=eyJwIjoyfQ
    GET /v1/events/evt_7781
    GET /v1/venues/ven_12/layouts/7
    GET /v1/events/evt_7781/availability
    GET /v1/events/evt_7781/availability/stream?since=1042

    Availability returns a version number and a compact bitmap; the stream sends changes after that version. Holding seats is the write that must be exactly right; during a hot on-sale it also needs the waiting room’s admission token.

    POST /v1/events/evt_7781/holds
    Authorization: Bearer USER_ACCESS_TOKEN
    Admission-Token: <signed waiting-room token, hot on-sales only>
    Idempotency-Key: 4f1c9e0a-2b7d-4a51-9a0e-5d3c2f1b8e77
    Content-Type: application/json
    
    { "seat_ids": ["C-K-14", "C-K-15"] }
    {
      "hold_id": "hld_91ab",
      "seats": ["C-K-14", "C-K-15"],
      "total_inr": 9000,
      "expires_at": "2026-01-18T12:10:00+05:30"
    }

    Success is 201 Created with that body; if any seat is gone, 409 Conflict lists it and nothing is held. A fan can instead send {"section": "C", "quantity": 4, "best_available": true} and let the server choose.

    Checkout and confirmation:

    POST   /v1/holds/hld_91ab/checkout   → { "booking_id": "bk_5521", "payment": { "intent_id": "pi_3Lx…", "upi_uri": "upi://pay?…" } }
    GET    /v1/bookings/bk_5521          → { "status": "CONFIRMED", "tickets": [ … ] }
    DELETE /v1/holds/hld_91ab            → release the seats early
    POST   /internal/payments/webhook    ← the PSP reports the outcome (signed, retried)

    Back-of-envelope estimates

    Assume 100M monthly users across India and abroad, 20M of them active on a given day, and 1M bookings a day averaging 2.5 tickets. (The method is in back-of-envelope estimation.)

    Everyday reads. About 20 read calls per active user per day — searches, event pages, seat maps, availability: 20M × 20 = 400M a day, and 400M ÷ 86,400 ≈ 4,600 reads/s. Evenings run about 3× the average: ≈ 14,000 reads/s at peak.

    Everyday writes. 1M bookings ÷ 86,400 ≈ 12 bookings/s. Fans abandon checkouts and lose seats, so assume two holds per booking: 24 holds/s. Add 12 confirmations and 12 releases of holds that never convert, and that is about 50 writes/s on average, 250/s at a 5× peak. Reads per write: 4,600 ÷ 50 ≈ 90:1 — on an ordinary day, reads are the scaling problem.

    Inventory size. Cinemas: 8,000 screens × 5 shows × 200 seats = 8M event-seat rows a day. Live events: 2,000 shows × 5,000 seats = 10M. With sales open 30 days ahead: (8M + 10M) × 30 = 540M live rows, and at about 100 bytes each, 540M × 100 B = 54 GB (perhaps 110 GB with indexes). You will shard for write isolation, not size.

    Seat map payload. A 60,000-seat layout at about 40 bytes per seat (id, section, row, number, x, y) is 60,000 × 40 B = 2.4 MB — download once, cache for days. Availability at one bit per seat is 60,000 ÷ 8 = 7,500 bytes, 2.4 MB ÷ 7.5 KB ≈ 320× smaller.

    The hot on-sale. A stadium show: 60,000 seats and 2M fans — 1.2M waiting in the pre-queue at noon and 0.8M more in the next ten minutes. With no waiting room, if each browser refreshes or retries every 2 seconds: 2M ÷ 2 = 1M requests/s, about 70× the everyday peak (1M ÷ 14,000 ≈ 71), all aimed at one event’s rows. With a waiting room whose page polls one counter every 10 seconds: 2M ÷ 10 = 200,000 polls/s, all for the same number, which the CDN can answer.

    What the booking tier must absorb. Admit 200 fans a second. Each spends about 5 minutes choosing and paying and makes about 4 writes (two hold attempts, checkout, confirm): 200 × 300 s = 60,000 shoppers at once and 200 × 4 = 800 writes/s on one event. Ticketmaster reports that about 40% of its invited fans buy, 3 tickets on average. At those rates 60,000 seats ≈ 20,000 orders, and the show sells out after 20,000 ÷ 0.4 = 50,000 admissions — 50,000 ÷ 200 = 250 s, just over 4 minutes.

    Live seat-map fan-out. One 200-byte batch of changes per second to each of 60,000 viewers is 60,000 messages/s and 12 MB/s for one event — 3 push servers at a conservative 20,000 connections each.

    Tip

    In a booking system the dangerous number is not total QPS but writes per second on one event’s rows. Everyday booking writes are tiny. The waiting room exists to keep the on-sale number in the hundreds, not the hundreds of thousands.

    High-level design

    Build the design one requirement at a time. By the end it works for an ordinary day; the deep dives make it survive the extraordinary one.

    Users should be able to search and browse events

    Browsing is most of the traffic, and almost all of it is cacheable. Fans reach a CDN first, which answers popular event pages and searches. Misses go through an API gateway (authentication, routing, rate limits) to two read services: the event service reads the Events DB, the PostgreSQL source of truth for the catalogue, and the search service queries a search index such as Elasticsearch, fed asynchronously from the Events DB.

    Step 1: the read path for search and event pages. The CDN answers most requests; misses reach two read services.Notice Search never touches the seat inventory. The index is a copy of the catalogue that may trail it by about a second, and that is fine for browsing.

    Two habits keep this path cheap. Every query carries a city — BookMyShow asks for it first — so result sets are small and cache keys repeat. And event pages are cached at the CDN for about a minute, so ten thousand fans opening one page cost the origin one request. A new show appearing in search a second late hurts nobody.

    Users should be able to view an event and its seat map

    A seat map has two halves that behave differently. The layout — where every section, row and seat is drawn — is about 2.4 MB for a stadium, never changes during a sale and is shared by every show at the venue: put it in object storage under a versioned URL and let the CDN cache it for days. The availability — which seats are free now — is 7.5 KB and changes every second; an inventory service builds it from the seat rows.

    Step 2: the seat map is assembled in the browser from a static layout and a small, live availability bitmap.Notice The size difference: the layout is downloaded once per venue, while only 7.5 KB of status ever needs refreshing.

    For now the inventory service just queries the database — SELECT seat_id FROM event_seats WHERE event_id = $1 AND status <> 'AVAILABLE' — and returns a bitmap, one bit per seat in layout order; the browser greys out those seats. That is fine for a 200-seat cinema screen, not for 60,000 people watching one stadium map; that is a deep dive.

    Users should be able to hold specific seats

    When a fan clicks “Proceed” with two seats selected, the booking service must claim both, atomically, and only if nobody else has them. Make the hold data in the seat row — a status, a hold ID and an expiry — and claim it with one conditional UPDATE in a short transaction:

    -- One short transaction: all requested seats or none.
    UPDATE event_seats
    SET    status = 'HELD',
           hold_id = 'hld_91ab',
           hold_expires_at = now() + interval '10 minutes'
    WHERE  event_id = 7781
      AND  seat_id IN ('C-K-14', 'C-K-15')
      AND  (status = 'AVAILABLE' OR hold_expires_at < now());
    -- 2 rows updated: COMMIT and return 201 with expires_at.
    -- Fewer: ROLLBACK and return 409 listing the seats that were taken.

    The last line of the WHERE clause does the work: a seat qualifies if it is available or its hold has expired. A sold seat has no expiry (NULL), so it never qualifies.

    Step 3: the booking service claims seats with one conditional UPDATE on the event_seats rows.Notice Seat C-K-15: its old hold expired at 11:58, so the new hold takes it even though no cleanup job has run yet.

    The transaction lasts milliseconds; during the ten minutes the fan then has to pay, no database lock is held — the hold is just data every other request respects. An idempotency key makes a retried “Proceed” return the same hold instead of failing against itself, and one active hold per account per event stops anyone parking twenty seats.

    Users should be able to pay and get a ticket

    Checkout turns the hold into a booking with status PENDING_PAYMENT and asks a payment service to create a payment intent with the PSP, using the booking ID as the idempotency key so a retry never charges twice. The fan approves in their UPI app or on a card page. The PSP reports the result by webhook, and the booking service confirms in one transaction: booking CONFIRMED, seats flipped from held to sold only if the hold is still theirs, and a TicketIssued event written to an outbox table. A ticket service reads it, generates a QR code per seat, and a notification service sends the e-ticket by SMS, e-mail or WhatsApp.

    Step 4: from checkout to e-ticket. The seats stay held, not locked, while the fan pays.Notice Confirmation is driven by the PSP’s webhook, not by the browser. A fan who closes the tab after paying still gets their ticket.

    The transactional outbox matters: if the service wrote the sale and then published to a queue separately, a crash in between would leave a fan who paid without a ticket. One transaction makes “sold” and “ticket will be issued” succeed or fail together.

    You now have a platform that works: cached browsing, a seat map, holds that cannot collide, and paid bookings that always produce tickets. Now push it.

    Deep dives

    How do we stop two people booking the same seat?

    At 12:00:03, Asha in Pune and Ravi in Chennai both click seat C-K-14. Exactly one may get it, and the other must be told quickly. Four approaches come up; the first two are traps.

    Bad: read, then write. The obvious code reads the seat, checks it is available, then writes HELD. Both requests read “available” before either writes, both write, and both fans are told the seat is theirs. This is the classic lost update, and PostgreSQL’s default isolation level, read committed, does nothing to stop it.

    Two fans race for seat C-K-14. Left: read, then write. Right: one conditional UPDATE each.Notice On the left both requests made a correct decision from what they read. The bug is the gap between the read and the write, and the conditional UPDATE removes it.

    Bad: lock the row for the whole checkout. SELECT … FOR UPDATE closes the gap: the second request waits until the first transaction ends. The mistake is ending it only after payment. Every fan in checkout now pins a connection and an open transaction for up to ten minutes. With a pool of 500 connections, at most 500 people can pay at once — against 60,000 shoppers — and anyone who clicks a locked seat queues behind it, because PostgreSQL waits indefinitely for a conflicting row lock unless told otherwise. Its manual is blunt: holding a transaction open while waiting for user input is a bad idea. Row locks are for milliseconds, not minutes.

    Good: make the hold data, and claim it with a conditional update. This is the high-level design: the check and the write are one statement. PostgreSQL still takes a row lock, but only while that statement runs. If Ravi’s UPDATE arrives while Asha’s is in flight, it waits for her commit, re-evaluates its WHERE clause against the new row, finds HELD, updates nothing and returns 409. This is optimistic concurrency: nothing is locked between requests, and conflicts are detected at write time; a version column is the same idea in general form.

    Optimistic schemes have a reputation for collapsing under contention, because losers retry. Not here: a fan who loses C-K-14 does not retry C-K-14 — “taken” is final — so each loser costs one cheap failed statement, not a retry storm.

    Two refinements matter once a hold covers several seats:

    • Lock in a fixed order, and never wait. Two fans claiming seats 14–15 and 15–16 at once can deadlock if their rows lock in opposite orders. PostgreSQL detects and aborts one, but its manual advises always locking in the same order. Lock first with SELECT … ORDER BY seat_id FOR UPDATE NOWAIT: the order rules out deadlocks, and NOWAIT turns “queue behind someone else’s hold” into an immediate error that you return as 409.
    • All or nothing. If one of four seats is gone, hold none and name the missing seat so the page can suggest another block.

    Great: let the database decide, and let Redis absorb the stampede. The conditional update is correct at any scale; what changes in a hot on-sale is the number of losers. In the first seconds thousands of fans click the same front-row seats, and every losing click is a database transaction. For hot events, put a filter in front: the booking service first claims each seat in Redis with SET hold:{7781}:C-K-14 hld_91ab NX PX 600000 — set only if absent (NX), expire after 600,000 ms (PX). For several seats, a short Lua script sets all keys at once; the {7781} hash tag keeps one event’s keys in one cluster slot. Losers are turned away in memory in well under a millisecond; only winners go on to the conditional UPDATE.

    Redis is a filter, never the source of truth. Its replication is asynchronous, so a failover can forget a hold and let a second fan through — and the database then rejects them. The worst case is a disappointed fan, never a double sale. You pay with two stores to keep in step and one more dependency; if Redis is down, skip the filter: slower, still correct.

    Approach Double sale possible? Cost of one hold In a hot on-sale How holds expire
    Read, then write Yes — lost update Two statements Double sales at exactly the worst moment Needs a separate job
    Row lock held through payment No A connection and an open transaction for up to 10 min Checkouts capped by the connection pool (500) When the transaction ends or the connection dies
    Status + expiry, conditional UPDATE No One transaction of a few ms Every losing click is a cheap failed statement Lazily in the WHERE clause, plus a sweeper for display
    Redis SET NX PX filter + conditional UPDATE No — the database decides One Redis call; a transaction only for winners Losers rejected in memory Redis key expiry plus the database expiry
    Where the hold lives

    Expiring holds. The conditional update already treats an expired hold as free. That is lazy expiry: correctness never depends on a background job running on time. But the map would show the seat as taken until someone tried it, so a sweeper runs every few seconds:

    UPDATE event_seats
    SET    status = 'AVAILABLE', hold_id = NULL, hold_expires_at = NULL
    WHERE  status IN ('HELD', 'PAYING') AND hold_expires_at < now()
    RETURNING event_id, seat_id;

    A partial index on hold_expires_at over held and paying rows keeps it cheap, and each returned row becomes a “seat available again” change for the live map. A delay queue with one message per hold is more punctual, but you keep the lazy check anyway, because messages can be late. The sweeper is about display; the WHERE clause is about correctness.

    The life of one event seat. Forward moves are conditional updates; every way back to AVAILABLE is a release, an expiry or a refund.Notice No arrow leads from SOLD to HELD or PAYING. Once sold, a seat changes hands only through an explicit cancellation.

    The PAYING state, which covers the minutes when money is moving, gets its own deep dive below.

    How do we survive a hot on-sale?

    Recall the estimate: 2M fans for 60,000 seats, about 1M requests a second if nothing stands in the way, against a booking tier that wants a few hundred writes a second. India runs a version of this every morning: Tatkal train tickets open on IRCTC at 10:00 for AC classes and 11:00 for non-AC, one day before the journey, and demand piles into the first minutes.

    Bad: let everyone in and autoscale. Autoscaling reacts in minutes; this spike arrives in seconds. And the bottleneck does not scale: one event’s rows live on one database primary, and more app servers just queue more transactions in front of it. Even unlimited capacity would not create seats — two million people chasing 20,000 orders turns a capacity problem into a contention problem.

    Good: rate-limit at the gateway. A token bucket rejects everything above, say, 2,000 requests a second with a 429 (see design a rate limiter). SeatGeek describes this layer protecting services such as venue maps during big on-sales, and it keeps the origin alive. As an admission policy, though, it is a lottery won by retrying: whoever hammers hardest gets in, which favours bots, and everyone else sees an error page.

    Great: a virtual waiting room. Turn the crowd into an ordered line at the edge, and let people through at the rate the booking tier can serve — load shedding with manners.

    A stadium on-sale minute by minute: about 2M fans arrive, but the booking tier admits a steady 12,000 a minute.Notice The scale: the admission line is barely above the axis. The waiting room, not the booking tier, holds almost everyone, and most fans in it will be told the show is sold out.

    Five steps, numbered as in the next figure:

    1. Join. From about 30 minutes before the on-sale, the event page redirects to a static waiting-room page on the CDN, which joins the queue and receives a queue ticket signed by the queue service: event, account and position — 48,213, say. Positions come from one Redis counter per event (INCR). Everyone who joined before noon is in a pre-queue that is shuffled at 12:00 like a raffle (random scores in a sorted set), so arriving at 11:31 or 11:59 gives the same chance — Queue-it documents exactly this for sales with a fixed start. Later arrivals join the back in order.
    2. Wait. The page polls the serving counter — “now admitting up to 48,200” — every 10 seconds. Everyone asks for the same value, so the CDN caches it for two seconds and 200,000 polls a second become a handful of origin requests.
    3. Admit. Once the counter passes the fan’s position, the page swaps the ticket for an admission token: a JWT bound to account and event, valid for 15 minutes, issued idempotently. AWS’s reference waiting room has the same shape: queue position against a serving counter, idempotent token issue, tokens verified with a published public key.
    4. Book. Every booking call carries the token, and the gateway checks its signature and expiry with the public key — no lookup, no shared state.
    5. Pace. An inlet controller moves the serving counter. AWS’s samples add a fixed number per minute or keep concurrent users under a ceiling; combine both with inventory. Admit while hold p99 stays under 200 ms and errors under 1%, never exceed 60,000 active shoppers, and stop when the unheld seats cannot cover the shoppers inside. When everything is sold or held, say so — “sold out; we’ll tell you if seats come back” — rather than let 1.9M people wait for nothing.
    The waiting room: a signed ticket for your place in line, one cached number to poll, and a signed token to get in.Notice Nothing in the waiting room touches the inventory database, and the gateway checks tokens with a public key alone.

    The queue never touches the inventory database. Its state is a counter, a sorted set of about 1.2M entries (on the order of 100 MB in Redis) and the serving counter. Each fan’s position travels in their own signed ticket, so polls need no lookup; persist the serving counter every second and even a Redis restart costs fans nothing.

    Random or first come, first served?

    First come, first served rewards whoever polls fastest — bots and scripts — and crams arrivals into the last second before the sale. Randomising the pre-queue spreads arrivals over half an hour and makes speed worthless. The price: early arrivals feel cheated, so state the rule on the waiting-room page.

    What you pay: fans wait, and most will not get tickets — about 50,000 of 2M are admitted before the sell-out — so honest, early messaging matters as much as code. The waiting room becomes a critical dependency, so keep it static and edge-served, and fail closed: if the queue service is down, the sale pauses rather than opening the floodgates. And the inlet rate is a dial you will turn under pressure: during the Eras Tour sale, Ticketmaster says it slowed some sales and pushed back others to stabilise its systems, trading longer queues for stability. It also notes a stadium show usually takes about an hour to sell through.

    How do we show live seat availability to thousands of viewers?

    Past the waiting room, 60,000 shoppers watch one stadium map while seats change hundreds of times a second. The step-2 design would turn every refresh into a query over 60,000 rows.

    Bad: poll the full seat list. Status per seat as JSON ("C-K-14": "SOLD") is about 20 bytes a seat, 1.2 MB for the stadium. With every viewer polling every 5 seconds: 60,000 × 1.2 MB ÷ 5 s ≈ 14 GB/s of responses, each one a database query.

    Good: poll a cached bitmap. The availability service keeps each hot event’s bitmap in memory, updated from seat-change events, with a version number, and the CDN caches it for two seconds: 60,000 × 7.5 KB ÷ 2 s = 225 MB/s (about 1.8 Gbit/s) from the CDN, and one origin request per edge location every two seconds. Simple and robust, but changes show up two to four seconds late — plenty of time for clicks on seats already gone.

    Great: a snapshot plus pushed deltas. Load the snapshot once, then subscribe to changes.

    • Every seat change already writes an outbox event, and these flow into a Kafka topic keyed by event ID, so one event’s changes stay in order.
    • Once a second, the availability service pushes one batch to subscribers over server-sent events (SSE): an id: line with the new version and a data: line listing seats just taken and freed.
    • The browser loads snapshot v1042 from the CDN, opens /availability/stream?since=1042 and applies batches in order; a gap in versions means “refetch the snapshot”.
    • After a dropped connection the browser reconnects by itself and sends the last event ID it saw; the server replays from a short ring buffer, or says “refetch”. A comment line every 15 seconds stops idle proxies closing the stream.

    About 200 bytes a second per viewer is 12 MB/s for all 60,000, with changes visible within about a second.

    A cached snapshot, then a stream of numbered deltas that can resume after a dropped connection.Notice The version numbers: they let the browser detect a missed batch and let the server resume a stream exactly where it broke.

    Why SSE rather than WebSockets? Data flows one way, and SSE is ordinary HTTP that passes through CDNs and proxies, with reconnection built into the browser. Serve it over HTTP/2: over HTTP/1.1 a browser allows only six SSE connections per domain, so a fan with several tabs would starve. WebSockets are fine if you already run them. See real-time connections and real-time updates.

    In the interview

    Say it out loud: “The seat map is a hint; the hold is the check.” Even pushed deltas are a second old, and two fans can click the same green seat in the same second. Don’t chase a perfect map; make the hold fast and its failure graceful — on a 409, grey out the lost seat, keep the others selected and suggest the nearest free ones.

    How do we make event search fast?

    Bad: SQL LIKE on the Events DB. WHERE title ILIKE '%coldplay%' cannot use an ordinary index, has no relevance ranking or typo tolerance (“coldpaly”), and competes with catalogue writes on the same database.

    Good: a search index fed by change data capture. Keep PostgreSQL as the source of truth and maintain an Elasticsearch index with one denormalised document per event: title, performers, venue, city, a geo point for “near me”, start time, genre, language, price range and an availability badge. A change data capture connector reads the Events DB’s write-ahead log into Kafka and an indexer applies the changes — no dual writes that drift apart. Elasticsearch refreshes every second by default, so a new show is searchable within a second or two. Ranking and typeahead are the same problems as in product search for an online store.

    Great: cache the popular answers, and keep seat churn out of the index. A few queries per city — “movies in Mumbai today” — dominate, so normalise them and cache results at the CDN for 30–60 seconds, with stale-while-revalidate on event pages. A shield location in front of the origin means one miss per region, not one per edge. SeatGeek describes this set-up and its trap: failed responses are not cached, so an overloaded origin gets more traffic, not less — gateway rate limits keep it healthy enough to keep the cache warm. And do not stream every seat change into the index: update the badge (“available”, “filling fast”, “sold out”) only when it crosses a threshold, at most once a minute per event.

    How do we scale the booking tier for one hot event?

    Shard the inventory by event_id: an event’s seats and bookings live on one shard, so a multi-seat hold or a confirmation is one local transaction. Sixteen shards, each a primary with a synchronous replica for zero data loss, turn the 250 peak writes a second into about 16 per shard. A fan’s “my bookings” page reads a separate table keyed by user, updated asynchronously.

    The trouble is the event that is not average. At noon one shard receives the on-sale’s 800 writes a second, the sweeper’s churn and the availability reads — while hosting a sixteenth of every other event. That is a hot partition, and everything else on it suffers as a noisy neighbour.

    Bad: shard by seat instead. Writes spread perfectly, but a four-seat hold now spans shards and needs a distributed transaction, and every seat-map read fans out. One hot spot becomes coordination on every request.

    Good: isolate the hot event before it is hot. On-sales are scheduled days ahead — a gift. Move the event to a dedicated shard, or better a dedicated cell with its own booking pods, Redis and database, then pre-warm caches and reserve capacity. If that cell has a bad day, Friday-night movie bookings carry on.

    Great: split the event by section, and let the server choose seats. Make the partition key (event_id, section_group). A 60,000-seat stadium with 80 sections becomes 8 groups of about 7,500 seats on 8 shards: 800 writes/s ÷ 8 = 100 writes/s per shard. If one hold stays within one section — fans buy seats together anyway — every hold is still single-shard, and the snapshot is 8 small bitmaps stitched together. The cost: cross-section holds must be refused or run as a saga, and moving sections between shards is tooling you must build.

    One hot event on a shared shard (left) versus the same event in its own cell, split into eight section groups (right).Notice The fix is mostly planning: the on-sale time is known days ahead, so the cell can be carved out and warmed before the first fan arrives.

    Then attack contention itself. Most fans don’t want C-K-14 in particular; they want “the best four seats in block C at ₹4,500”. Offer best available: the fan picks quantity and price, the server picks seats. With FOR UPDATE SKIP LOCKED, concurrent requests skip rows another transaction has just locked instead of queueing, so a thousand simultaneous requests get a thousand different sets of seats rather than one winner and 999 conflicts — the queue-like contention the PostgreSQL manual describes SKIP LOCKED for.

    -- Best 4 available seats in block C at tier 2; concurrent callers get different seats.
    SELECT seat_id
    FROM   event_seats
    WHERE  event_id = 7781 AND section_id = 'C' AND price_tier = 2
      AND  status = 'AVAILABLE'
    ORDER  BY quality_rank
    LIMIT  4
    FOR UPDATE SKIP LOCKED;
    -- then the usual conditional UPDATE on exactly these seats, in the same transaction

    A real allocator ranks blocks of adjacent seats rather than single seats, but the principle holds: hand out different seats instead of refereeing fights over the same ones.

    General admission is where this design meets a counter. A 5,000-place standing pit has no seat IDs — UPDATE ga_inventory SET remaining = remaining - 2 WHERE event_id = 7781 AND remaining >= 2 — and that one row becomes the hottest in the system. Bucketed counters, atomic decrements in Redis and asynchronous order creation are the core of design a flash sale.

    How do we keep bots and scalpers out?

    Scalpers buy tickets to resell at a markup, and their bots are fast, numerous and patient. Ticketmaster blames “a staggering number of bot attacks”, along with fans without codes, for its 3.5 billion Eras Tour requests, and reports that on-sales run through its pre-registration programme see under 5% of tickets reach resale sites, against 20–30% otherwise. No single control works; you stack cheap ones.

    Bad: a CAPTCHA on the buy button. Solving services pass CAPTCHAs for a fraction of a rupee, and by then the bot is already inside the booking tier, holding seats.

    Good: limits on identity. Six tickets per account per event, enforced at confirmation across all its bookings; one active hold per account; rate limits on hold calls per account, device and IP. Each account does less damage, but a farm of 10,000 accounts shrugs.

    Great: make identity expensive before the sale and cheap to check during it.

    • Pre-registration, days ahead. Fans register with a verified phone number; account age, history and payment instruments are scored offline, with time to think. When demand dwarfs supply, invite a subset: Ticketmaster had 3.5M registrations and sent codes to about 1.5M.
    • One gate at queue entry. Run bot scoring or a CAPTCHA once, at the edge, when a fan joins — before any booking capacity is spent. One queue position per account.
    • Tokens bound to the fan. The admission token carries the account ID and is rejected on any other session, so admission cannot be resold.
    • No seat hoarding. Bots also hold seats they never buy, to starve fans or prop up resale prices. One active hold per account, a cap on total hold time per event, and token-gated holds make that expensive.
    • After the sale. Flag one card or UPI ID across many accounts, cancel clear fraud, deliver tickets in the app close to the event, and allow transfers only through official resale at face value.
    Bot defence in layers, from days before the sale to after it. Red text shows what each layer stops.Notice Where the expensive checks sit: before the sale, when there is time to think, or at queue entry, before any booking capacity is spent.

    What you pay: friction for fans, and false positives — a family buying on one card looks like a ring. Keep a human appeal path, and tune cancellation for precision over recall.

    How do we confirm a booking exactly once when payments are slow?

    Payment is the slowest, least predictable step. A UPI request can sit on a phone for minutes, a card may need a 3-D Secure challenge, a PSP call can time out with an unknown outcome, and webhooks arrive at least once — sometimes late, sometimes twice. Three things must hold: never take money for seats you cannot give, never give a seat twice, never leave a paying fan without a ticket.

    Bad: charge first, and hope the hold survived. If the payment completes at 12:10:20 and the hold expired at 12:10:00, another fan may hold the seats by now. You have taken ₹9,000 for seats you cannot deliver.

    Good: freeze the hold while money moves. When the fan clicks Pay, move the seats from HELD to PAYING with an expiry of the PSP’s payment timeout plus a margin — 15 minutes, say. Make confirmation idempotent end to end: the booking ID is the PSP idempotency key, and confirmation is a conditional state change, so a duplicate webhook finds the booking already CONFIRMED and does nothing. PSP-side retries and unknown outcomes belong to design a payment system.

    Great: plan for the payment that lands too late anyway. Banks are sometimes slower than any window. The confirmation rule handles it: take the seats if they are still ours or still free; otherwise refund automatically.

    BEGIN;
    UPDATE bookings SET status = 'CONFIRMED'
    WHERE  booking_id = 'bk_5521' AND status = 'PENDING_PAYMENT';
    -- 0 rows: a duplicate webhook or a cancelled booking. Stop here.
    
    UPDATE event_seats
    SET    status = 'SOLD', booking_id = 'bk_5521',
           hold_id = NULL, hold_expires_at = NULL
    WHERE  event_id = 7781 AND seat_id IN ('C-K-14', 'C-K-15')
      AND  (hold_id = 'hld_91ab' OR status = 'AVAILABLE' OR hold_expires_at < now());
    -- 2 rows: insert TicketIssued into the outbox, then COMMIT.
    -- Fewer: ROLLBACK, mark the booking LATE_PAYMENT and start a refund.
    COMMIT;

    Because bookings are sharded by event like the seats, both updates and the outbox insert are one local transaction — no distributed commit anywhere.

    Two payments for the same kind of booking: one confirmed thanks to the PAYING window, one refunded because it arrived after the window closed.Notice The first payment lands at 12:11 — after a plain ten-minute hold would have expired — and still succeeds.

    For cards, authorise at payment and capture only after the confirmation commits, so a lost seat means voiding an authorisation rather than refunding money. UPI moves money at once, so a lost seat means a refund — tell the fan immediately, with the reference. A daily reconciliation against the PSP’s settlement report catches the rest: a payment with no booking, or a booking stuck in PENDING_PAYMENT. Ticket issue is idempotent on the booking ID, so a redelivered outbox event never mints a second QR code (see idempotent consumers).

    The final design

    Everything a fan reads comes from a cache or an index; everything that changes a seat is one conditional statement on the event’s shard; and the crowd waits at the edge.

    The complete ticketing platform, with the seven request paths numbered.Notice Every change to a seat — hold, pay, confirm — is one conditional statement on one event’s shard. Everything else is cached, queued or pushed.

    Follow a fan through a hot on-sale:

    1. Search and browse. “Coldplay Ahmedabad” is answered from the CDN if anyone asked in the last minute; otherwise the catalogue queries the search index that CDC keeps in step with the Events DB.
    2. Queue. The event page redirects to the waiting room. The queue service issues a signed ticket, shuffled with the pre-queue at noon; the page polls the serving counter cached at the CDN, which the inlet controller advances by about 200 a second while the booking tier is healthy and seats remain. Then the ticket is swapped for an admission token.
    3. Seat map. The layout comes from the CDN, the availability snapshot from the availability service (cached two seconds), then an SSE stream of numbered deltas fed from Kafka.
    4. Hold. The gateway verifies the token with the public key. The booking service filters hot seats through Redis, then runs one conditional UPDATE on the event’s shard — in its own cell for this on-sale — holding seats for ten minutes or returning 409.
    5. Pay. Checkout moves the seats to PAYING for 15 minutes and creates a payment intent keyed by the booking ID.
    6. Confirm. The PSP’s webhook reaches the payment service, which asks booking to confirm: one transaction marks the booking confirmed, flips the seats to SOLD if still ours or free, and writes TicketIssued to the outbox; otherwise the payment is refunded.
    7. Fan out. Outbox events and sweeper releases flow through Kafka to the availability service, which pushes deltas to open seat maps, and to the ticket service, which sends the e-ticket.

    Check it against the requirements. A seat is only claimed by a conditional statement on one shard, so it cannot be sold twice. Browsing runs on caches and an index that tolerate seconds of lag, so it stays up even when booking pauses, and absorbs the 90-to-1 read load at the edge. The on-sale’s 1M requests a second become 200,000 cacheable polls and about 800 writes a second on one event. Holds expire lazily the instant they lapse and visibly within seconds. The shuffled pre-queue and layered bot defences keep it fair, and the outbox, idempotent confirmation and reconciliation leave no paying fan without a ticket.

    What interviewers look for by level

    Area Mid-level Senior Staff
    Requirements Search, seat map, booking Separates AP browsing from the CP seat; asks about assigned seats versus general admission and on-sale size Treats browse and booking as two systems with different SLOs; makes fairness and bot resistance explicit requirements
    Double booking “Use a transaction” Conditional UPDATE or version check; explains the read-then-write race and why row locks across payment fail Lock ordering and NOWAIT, all-or-nothing multi-seat holds, Redis as a filter with the database as arbiter
    Holds and payment A TTL somewhere Status plus expiry, lazy expiry, a sweeper for display PAYING window, late-payment refunds, idempotent confirmation, outbox, reconciliation
    Hot on-sale Autoscaling and a cache Waiting room with tokens and an admission rate Signed tokens checked at the edge, shuffled pre-queue, inventory-aware inlet, fail-closed queue, honest sold-out messages
    Seat map Poll the API Cached bitmap with versions Snapshot plus SSE deltas with resume; sizes the fan-out; “the map is a hint”
    Scaling data One database Shards by event; search via CDC; caches reads Per-event cells set up before the on-sale, section sub-partitions, best available with SKIP LOCKED
    Abuse Rate limits Per-account caps and a CAPTCHA Layered defence: pre-registration, queue-entry checks, bound tokens, hold-time caps, post-sale review

    Follow-up questions

    What if four friends want seats together and no four adjacent seats are free?

    Best available searches for blocks of four adjacent seats, ranked by quality, and holds the chosen block with the same all-or-nothing update. If none exists, offer splits — two and two in consecutive rows, or across an aisle — and let the fans choose. Good allocators also avoid leaving a lone orphan seat between sold ones, since it is hard to sell; they score blocks by the gaps they would leave.

    What if the inventory database’s primary fails in the middle of an on-sale?

    The synchronous replica is promoted, so no acknowledged hold or sale is lost. For the tens of seconds failover takes, holds fail with 503 and Retry-After, and the inlet controller drops admissions to zero so new fans wait in the queue rather than pile onto a broken tier. Admitted fans’ tokens and holds are extended by the pause. Stale Redis filter entries are harmless because the database re-checks every hold, and webhooks that failed meanwhile are retried by the PSP against an idempotent confirmation.

    What if the same seats are also sold at the box office or through partner apps?

    Keep one inventory service as the source of truth and make every channel hold through the same API — a box-office terminal is just another client. Fixed allocations per channel are simpler but strand seats, so unsold allocations must flow back before the show. In cinema ticketing it is common for seats to live in the cinema’s own box-office system, with ticketing apps holding through its API; then a hold is a remote call with unknown outcomes, needing the same idempotency keys and reconciliation as a payment.

    What if prices change while the sale is running?

    Quote the price when the hold is created and store it on the hold; checkout charges that quote for the life of the hold, and new prices apply only to new holds. The seat map shows the price tier with its version, but the hold response is the authority — the same “hint versus check” split as availability.

    What if fans want to resell tickets they cannot use?

    Run an official resale channel that reuses the booking flow: a listing is inventory, a buyer holds and pays, and confirmation transfers ownership in one transaction — the seller’s ticket is voided and a new barcode issued to the buyer, with the seller paid out after the event. The organiser can cap the price at face value. Because gates accept only the latest barcode, screenshots of resold tickets are worthless.

    How to say it in the interview

    “I’ll design a ticketing platform like Ticketmaster or BookMyShow: search events, view a seat map with live availability, hold specific seats for ten minutes, pay and get an e-ticket. The non-negotiable is that a seat is never sold twice; the hard case is a stadium on-sale where two million fans arrive for 60,000 seats.

    I treat it as two systems. Browsing is read-heavy — about 90 reads per write on an ordinary day — so event pages and popular searches are cached at the CDN, search runs on Elasticsearch fed from Postgres by change data capture, and the seat map is a static layout from the CDN plus a 7.5 KB availability bitmap.

    Booking is small in volume but must be exact. A hold is one conditional UPDATE on the seat rows: set HELD with an expiry, only where the seat is available or its old hold has expired. No locks are held while the fan pays, expired holds count as free immediately, and a sweeper only tidies up the map. When payment starts, the seats move to PAYING for fifteen minutes; the PSP’s webhook flips them to SOLD and writes a ticket event to an outbox in the same transaction. Confirmation is idempotent, and a payment that lands after the window is refunded automatically.

    For hot on-sales I put a virtual waiting room at the edge. Early arrivals are shuffled at the start time, everyone polls one cached serving counter, and admitted fans get a signed, account-bound token that the gateway checks with a public key. The inlet admits about 200 fans a second — roughly 800 writes a second on one event — and stops when seats run out. Seat-map changes go out as numbered SSE deltas, and the hold call is the real check.

    To scale the hot event I move it to its own cell before the sale, split it by section, and offer best available with SKIP LOCKED so fans don’t fight over one seat. Bots are handled in layers: pre-registration, a check at queue entry, bound tokens and per-account limits.”

    Concepts used in this design

    References and further reading

    1. Taylor Swift | The Eras Tour onsale explained (Ticketmaster, November 2022)business.ticketmaster.com3.5 billion system requests (4× the previous peak), 3.5 million Verified Fan registrations and about 1.5 million invited, about 40% of invited fans buying an average of 3 tickets, a stadium show usually selling through in about an hour, queues deliberately slowed, over 2 million tickets in one day, under 5% of tickets resold versus 20–30% without Verified Fan.
    2. Virtual Waiting Room on AWS: developer guidegithub.comQueue position versus serving counter, idempotent token generation, JWTs verified with a published public key, and the periodic and max-size inlet strategies.
    3. How does Queue-it work?queue-it.comFor a sale with a fixed start time, visitors who arrive early are randomised like a raffle; later arrivals are served first-in, first-out.
    4. Shielding the core: caching and rate limiting at SeatGeekchairnerd.seatgeek.comShield POPs in front of the origin, failed responses not being cached, a venue-maps service failing during a high-profile on-sale, and token-bucket limits at the API gateway.
    5. Redis SET commandredis.ioThe NX (only if absent) and PX (expiry in milliseconds) options that make a single-command, self-expiring hold.
    6. PostgreSQL documentation: explicit lockingpostgresql.orgRow-level lock modes, deadlock detection, locking in a consistent order, and the warning against holding transactions open while waiting for user input.
    7. PostgreSQL documentation: SELECT, the locking clausepostgresql.orgFOR UPDATE with NOWAIT (fail instead of waiting) and SKIP LOCKED (skip rows another transaction has locked, meant for queue-like contention).
    8. Indian Railways: Tatkal schemeindianrail.gov.inTatkal booking opens at 10:00 for AC classes and 11:00 for non-AC classes, one day before the journey.
    9. Elasticsearch: near real-time searchelastic.coIndices refresh every second by default, so a newly indexed document becomes searchable within about a second.
    10. MDN: Using server-sent eventsdeveloper.mozilla.orgEvent IDs and the retry field for reconnection, comment lines as keep-alives, and the six-connection limit per browser and domain without HTTP/2.