System Design Interview Guide

Design a Ticket Booking & Flash-Sale System
like Ticketmaster

Simple to describe, brutal to scale: 2 million people trying to buy the same 50,000 seats in the same ten seconds, and not one of them can be sold twice.

L4 — Build it L5 / L6: Own the tradeoffs L7 / L8 — Drive the architecture

~38 min read · 11 sections · interactive estimator

A stick figure sprinting through a stadium turnstile gate that is slamming shut behind a huge crowd, clutching a single ticket stub
01

What the interviewer is testing

"Design Ticketmaster" looks like a booking-system question, and candidates who treat it as one — CRUD an event, CRUD a seat, CRUD an order — miss the point entirely. The hard problem only shows up at one specific moment: the instant a high-demand show goes on sale, when a fixed, tiny inventory (a 50,000-seat arena) is hit by a burst of concurrent demand forty times larger (2,000,000+ people refreshing at once). For 99% of the calendar year this system looks like a boring read-heavy catalog. For the ten minutes that matter, it's a write problem with forty claimants for every seat.

This is a fundamentally different regime from most "design a booking system" interviews. Airbnb-style booking deals with low contention across huge inventory: 150 million distinct listings, and any two guests booking at the same instant are almost certainly booking different properties. The hard problems there are search freshness and geo-partitioning. Ticketmaster is the opposite regime: extreme contention on a tiny, single inventory — every one of those 2 million people is trying to write to the same few thousand rows in the same few seconds. Search and catalog design barely matter here; write arbitration is everything. If you find yourself reaching for Airbnb's playbook (shard by listing, decouple a search index), stop — there is effectively one "listing" and sharding it doesn't dissolve the contention, it just moves it.

ProblemWhy it's hardWhat changes at scale
Write contention on a hot inventoryThousands of seats, millions of simultaneous claimants; the naive "lock the row, check, write" pattern collapses a relational primary in secondsThe DB can no longer be the point of arbitration; a much higher-throughput layer has to absorb the contention before the DB ever sees it
Overselling vs. undersellingSell a seat twice and you have a legal and PR incident; hold a seat too conservatively and it sits empty while buyers get rejectedThe system needs a hold-then-commit model with a precise, auditable definition of the point of no return
Fairness under a thundering herdEvery client hits "buy" in the same second; first-come-first-served has to mean something even though network latency and client clock skew make raw arrival order unobservableAdmission has to be paced and ordered deliberately (a queue), not left to whichever request happens to win a race
🎯

Level signal: L4 candidates design a seat table and a "book seat" endpoint but don't notice what happens when 2 million people call it at once. L5 candidates identify the thundering-herd problem and reach for a lock or a queue. L6 candidates recognise that neither locking nor queuing alone is sufficient — they compose an admission gate, a fast contention layer, and a durable commit, and can say which step is reversible. L7/L8 candidates reason about the business tradeoff between strict fairness, seat-map UX ("let me pick a seat" vs. "give me best available"), and system load — and can say what relaxing the zero-oversell requirement would cost the business, and why this one shouldn't.

The rate limiter design post covers the token-bucket and sliding-window mechanics this article's virtual waiting room borrows wholesale for admission pacing (§8) — read that post for the algorithm, this one for how it's repurposed as a fairness queue rather than an abuse shield. And where Airbnb-style booking solves "don't double-book one of 150 million distinct listings," this post solves "don't oversell one of 50,000 seats when 2 million people want them in the same second" — same-shaped booking flow, opposite contention regime, different solution at every layer.

02

Requirements clarification

Functional requirements

CapabilityIn scopeOut of scope (for this interview)
Event browsingView an event page, its seat map and pricing tiersEvent search, recommendation and personalised discovery — a standard search index behind CDN-cached event pages, with none of this system's contention
Waiting roomOn high-demand events, admit buyers into the purchase flow at a controlled rate, in a fair orderCross-event queue prioritisation, loyalty-tier queue skipping
Seat selection & holdReserve one or more seats, all-or-nothing, for a short window while the buyer checks outGroup/party seating optimisation ("find 4 seats together")
Checkout & paymentAuthorize payment, convert a hold into a sold seat, then capture and issue ticketsFull payment gateway internals — see payment processing design for that
Cancellation / refundCancel an order, release the seat back to inventoryResale/transfer marketplace
Dynamic pricingPrice a section based on live demand signalsThe full pricing/ML model — only the interface and consistency implications are in scope

Non-functional requirements

NFRTargetWhy this level?
OversellingZero, structurally — not "rare"A sold-twice seat means turning away a paying customer at the door; the data model must make it impossible, not just unlikely
Seat-hold duration8 minutes, server-enforcedLong enough to complete checkout including payment entry; short enough that abandoned holds don't starve real demand
Waiting-room admission latencyNew admission batch every 2–5 s during an on-sale spikeFast enough that the queue feels alive, slow enough that each batch doesn't itself create a thundering herd against inventory
Seat-map render (p99)< 500 ms after admissionOnce admitted, a buyer expects the app to feel responsive; the waiting room already solved the hard latency problem upstream
Checkout completion (p99)< 3 s from "pay" to confirmationCard authorization dominates this budget; see the payment-processing post for the breakdown
Peak scale50,000-seat venue; 2,000,000 concurrent buyers at on-sale instantA representative "superstar tour" on-sale, the scenario that breaks naive designs
Durability of a committed saleRPO = 0: no committed sale lost on database failoverA sale that vanishes in a failover puts its seat back on the market, which is an oversell by another route
Availability (on-sale window)99.95% during the sale windowAn outage during the one ten-minute window that matters is the worst possible failure for this business

NFR reasoning

Zero structural overselling Drives §5, §6, §7, §10 ›

Unlike an airline overbooking a flight by a known statistical margin, a sold-twice concert seat has no fallback: there's no "next flight" to rebook the loser onto, just a customer standing in a seat someone else is sitting in. The system needs a data model where a second successful write for the same seat cannot happen, however heavy the load.

What this drivesA single durable commit point (§6): inserting the seat's row into sold_seats, whose primary key on (event_id, seat_id) rejects any second sale of the same seat (§7), on a database that replicates synchronously so a failover can't lose that row (§10). Everything upstream of that insert — the hold, the queue position — is allowed to be approximate, because it isn't the guarantee.
8-minute seat-hold duration Drives §5, §6, §10 ›

Too short and legitimate buyers lose their seat mid-checkout, which under this NFR set means they re-enter contention for a shrinking pool — a bad experience during the exact moment the business most wants a good one. Too long and abandoned holds (someone opens checkout in five tabs, completes one) sit on inventory that a real buyer behind them can't touch.

TradeoffEight minutes is a product choice. It trades a small amount of held-but-idle inventory for materially fewer abandoned-checkout complaints. In practice the TTL should be jittered slightly (8 minutes ± 15–30s): most holds get created within the first few seconds of the sale (~12 s to sell out at §3's defaults), so an un-jittered fixed TTL releases a large batch of seats back to "available" in lockstep roughly eight minutes later — a smaller, self-inflicted version of the exact thundering herd this design exists to absorb. Re-contention on the returned seats is real but much lower than the on-sale instant, since most of the original 2M buyers have already been admitted, seated, or dropped off by then.
Waiting-room admission pacing Drives §4, §5b, §9 ›

If all 2,000,000 buyers hit the seat-selection API in the same second, no amount of caching or read replicas saves the write path — the problem isn't serving reads, it's arbitrating writes. Admitting buyers in small batches converts an unbounded burst into a controlled, sized stream that the contention layer in §5 is provisioned for.

What this drivesThe admission rate is a dial: too fast and it just relocates the thundering herd one hop downstream into the seat-hold layer; too slow and buyers queue for longer than the event is worth waiting for. §8 covers how this dial gets tuned in practice.
03

Capacity estimation

This is a contention-bound system. The dimensions that matter are how many writers contend for the same tiny set of keys, and for how long; request volume and storage barely register. Two numbers set the shape of everything downstream: the size of the inventory (small — tens of thousands of rows) and the ratio of demand to that inventory (huge — tens of buyers per seat).

Interactive capacity estimator

50,000
2,000,000
2,000
3
2
Contention ratio
—
buyers competing per seat
—
Time to sell out
—
at the admission rate above
—
Redis hold scripts/sec
—
contested hold attempts, peak
—
Orders to commit
—
the only rows Postgres ever sees
—
Postgres commits/sec, worst case
—
if every checkout took the same time
—
Buyers who never reach a seat
—
told "sold out"
—
💡

Key insight: at defaults, the seat-hold layer absorbs roughly 6,000 hold attempts per second (2,000 admitted buyers/sec × 3 attempts each) for the ~12 seconds it takes 25,000 two-seat orders to exhaust 50,000 seats — and none of that burst touches Postgres. The durable store sees only the ~25,000 orders that won, arriving as buyers finish checkout over the following minutes: 2,000 commits/s at the very worst, if every checkout took exactly the same time, and in practice tens to a few hundred per second. Every attempt hits Redis; only winners ever reach Postgres. That split is the architectural justification for §4 and §5: a very fast, non-durable arbitration layer (Redis) in front of a much slower, durable one (Postgres). The estimator counts buyers, not seats, in the contention ratio; measured in seats requested, demand at defaults is 80:1.

Numbers to know

The SLOs in §2 are stated in time, so it's worth being explicit about what each relevant latency number costs, and which decision it settles.

Latency: the numbers that set the budget

NumberValueWhat it settles
Checkout completion SLO< 3 s p99 (§2)The budget every other row below is measured against
L2 / Redis hit, same AZ~0.1–0.5 msA single seat-hold Lua script is a rounding error against the 3 s budget — which is why a buyer can afford a few hold attempts (§5) without threatening the SLO
Indexed point read/write, warm DB1–5 ms typical, 10 ms+ p99Even the durable Postgres commit (§6, §7) is cheap in isolation; the danger at scale is queueing behind lock contention, which §4/§5 keep off Postgres entirely
fsync on NVMe with power-loss protection~0.1–1 msThe order-ledger commit's durability cost is negligible against the 3 s budget; almost the entire budget goes to payment authorization (§6)
Round trip across AZs, same region~0.5–1 msWhat synchronous replication adds to each commit (§4, §10) — cheap against the 3 s budget, which is why RPO = 0 for committed sales costs almost nothing here
Cross-region round trip (US↔EU)~70–100 msA waiting-room admission token has to be verifiable without a cross-region call, or admission alone could eat a few percent of the checkout budget on a single lookup — this is why the token is a signed, self-contained JWT rather than a lookup (§5b)

Contention: the numbers that shape the design

DimensionEstimateKey insight
Inventory size~50,000 rowsFits in one Redis node's memory with room to spare — and its ~6,000 hold scripts/s fit one node's single command thread too (§9)
Peak contention ratio~40:1The defining number of this system — this is what makes it a write-arbitration problem, not a scaling problem
Seat-hold TTL8 min (§2)Bounds how long an abandoned hold keeps a seat off the market
Sell-out window~12 s at defaults (2 seats per buyer)The hold burst is brief; commits trail it for minutes at a far lower rate, so Postgres never sees the spike
04

High-level architecture

The architecture separates into four concerns that have almost nothing in common operationally: browsing (cold, read-heavy, ordinary), admission (the thundering-herd gate), seat contention (extreme write throughput, no durability requirement of its own), and commit (low throughput, strict durability requirement). Routing them to separate services lets each be provisioned for what it needs, instead of one service straining to be both very fast and very safe at once.

Ticketmaster high-level architecture Client Web / App API Gateway LB + Auth COLD READ PATH HOT PATH — ON-SALE INSTANT Catalog Svc browse, seat map Waiting Room admission gate Seat Hold Svc contention gate Checkout Svc commit + pay Payment Gateway external (Stripe/Adyen) Catalog DB Postgres (replicas) Admission Redis queue state + tokens Seat Redis holds (TTL) + SOLD keys Order DB Postgres, sold_seats Kafka order events (outbox) Notification Svc ticket confirmation Dynamic Pricing demand signal consumer LEGEND Synchronous Async / outbox Hot path
Figure 1 — High-level architecture. Browsing (top) is a normal cold read path. Admission, seat-hold and checkout form the hot path. Seat holds live only in Seat Redis; Checkout verifies the hold there, commits the sale to the Order DB, then marks the seat SOLD back in Redis. Kafka fans order events out to notification and pricing without slowing the commit.

Component breakdown

API Gateway terminates TLS, authenticates requests, and routes to the right service. It also enforces a first line of pacing — a standard per-IP/per-account rate limit (see rate limiter design) that catches naive scripted traffic before it even reaches the waiting room.

Catalog Service serves event browsing and the seat-map view from read replicas. It's an ordinary read-heavy service, kept separate so it never competes for capacity with the contention path. The seat map's free/held/sold overlay is the one live part; §8 covers how it stays fresh without reading Seat Redis per viewer.

Waiting Room is the admission gate for high-demand events. A buyer without a valid admission token is placed in a queue; the service admits buyers in controlled batches and issues each a short-lived, signed token. It also runs the first bot-defense checks (§10b) before a token is ever issued.

Seat Hold Service is the contention gate. It accepts a seat-hold request only from a caller presenting a valid admission token, and arbitrates concurrent claims using one atomic Redis script that claims all the requested seats or none (§5). It never talks to Postgres: holds, winners' included, live only in Redis until checkout.

Checkout Service owns the correctness-critical commit: it converts a hold into a paid order. It verifies the hold in Seat Redis, authorizes payment with the external gateway, inserts the sale into the Order DB — the commit point — and only then captures the payment (§6). If the insert loses, it voids the authorization and the buyer is never charged.

Admission Redis holds queue position and issued tokens. It's disposable in the sense that losing it degrades admission fairness, not correctness — nothing here is load-bearing for the zero-oversell guarantee.

Seat Redis holds the live state of every seat: free (no key), held (a key with the holder, the quoted price and a TTL), or sold (a key with no TTL). It's the system's highest-throughput store and the only place a hold exists — and, like Admission Redis, an arbitration layer rather than the source of truth. If it's lost, its sold keys are rebuilt from the Order DB (§10).

Order DB (Postgres) is the one component that must not lose a committed write: the durable ledger where the zero-oversell guarantee lives, as a primary key on (event_id, seat_id) in the sold_seats table (§7) that rejects a second sale of any seat. It replicates synchronously so a failover can't drop a sale (§2's RPO = 0). It sees only completed sales; every component in front of it exists to keep it that way.

Kafka carries order events out of the checkout transaction via the outbox pattern (see the equivalent pattern in Airbnb-style booking design): the Checkout Service writes an outbox row in the same transaction as the order commit, and a connector publishes it. Ticket-confirmation notifications and the dynamic-pricing demand signal both consume this stream independently, without adding latency to checkout.

Architectural rationale

Why put Redis in front of Postgres for the hold, instead of locking rows directly? Core tradeoff ›

§3 puts hold attempts at roughly 6,000/sec against a pool of 50,000 keys. A relational database can lock individual rows, but each attempt costs a connection, a transaction and WAL bookkeeping, and thousands per second fighting over a small keyspace exhaust the connection pool and push p99 well past what checkout can tolerate. A Redis instance executing a Lua script has none of that: commands run one at a time on a single thread, so the script completes atomically with no lock manager to contend on. And because a hold lives only in Redis, Postgres never sees a loser or an abandoned hold at all.

TradeoffRedis's hold state isn't durable across a node failure the way a WAL-backed Postgres row is. That's acceptable because a lost hold degrades to "buyer has to hold again," not to an incorrect sale: the sold_seats primary key still rejects a second sale — see §10.
AlternativesPostgres advisory locksIn-memory sharded lock service
Why a separate Waiting Room instead of just rate-limiting the Seat Hold API? Admission design ›

A rate limiter (§1 cross-link) caps throughput but doesn't establish fairness — under a flat rate limit, whichever requests happen to win the race to arrive first get through, and clients that retry more aggressively win more often. The waiting room instead assigns every buyer a queue position on arrival and admits strictly in that order, which is what "first come, first served" requires when arrival order at the network layer is not the same as arrival order at the person's browser.

What this drivesThe admission token (§5b) has to encode enough to prove "you were admitted, in order" without requiring a callback to the Waiting Room service on every downstream request — otherwise the gate just relocates its own bottleneck one hop later.
Why the outbox pattern for order events rather than a direct publish? Decoupling ›

Publishing directly to Kafka inside the checkout request means a slow or unavailable broker blocks a payment confirmation — unacceptable on the highest-stakes request in the system. Writing an outbox row in the same Postgres transaction as the order commit means the event's existence is as durable as the order itself, and a separate connector process publishes it independently, with retry, without the checkout request ever waiting on Kafka.

AlternativesDirect synchronous publishDual-write with reconciliation job

Real-world comparison

DecisionThis designTicketmaster (reported)Shopify (flash sales)
Admission controlVirtual waiting room, signed queue tokens"Verified Fan" queue + waiting room product (built on their own queueing layer)Shopify Queue-it-style waiting rooms for high-demand drops
Seat/inventory arbitrationRedis Lua script, all-or-nothing TTL holdReported use of in-memory data grids and distributed locks for inventory holdsRedis-backed inventory reservation with short TTL holds
Durable commit pointPostgres, primary key on (event, seat) in sold_seatsRelational order/ticketing systems with strict consistency on the seat mapRelational order ledger; oversell tolerated and resolved post-hoc for non-unique SKUs
Bot defenseProof-of-work / CAPTCHA at admission, device + account velocity limitsCAPTCHA, device fingerprinting, purchase-limit enforcement, Verified Fan registrationBot-detection middleware, checkout velocity limits
🌐

Shopify's flash-sale inventory is usually fungible (any unit of a SKU is interchangeable), so a brief oversell can sometimes be resolved after the fact with a refund and an apology. A concert seat is not fungible — there is exactly one physical seat with that row and number — so this design cannot borrow that tolerance and has to make the zero-oversell guarantee structural rather than probabilistic.

05

Core algorithm — locking a seat under a thundering herd

Once a buyer is admitted and looking at the seat map, claiming a seat comes down to one question: when several admitted buyers try to hold the same seat within milliseconds of each other, how does exactly one of them win? There are two classic answers — lock first, or check at commit — and this system uses both, at different layers.

Pessimistic vs optimistic locking for a seat hold ① PESSIMISTIC — LOCK FIRST SET seat:{evt}:{seat} hold NX PX 480000 1. Acquire lock atomically 2. Losers rejected in ~0.3 ms 3. Winner proceeds to checkout Wasted work under contention: none Losers never reach checkout Needs a TTL or a crash leaks the seat ✓ Our choice for the hold step ② OPTIMISTIC — CHECK AT COMMIT INSERT sold_seats — PK rejects 2nd 1. Every claimant proceeds freely 2. All but one fail the key at write time 3. Losers retry or are told "gone" Wasted work under contention: high 39 of 40 buyers do full checkout UI for nothing No lock to leak — self-cleaning ✓ Our choice for the final commit
Figure 2 — Pessimistic locking wins the hold step because it rejects losers in under a millisecond, before they've invested in checkout UI. The optimistic check is the structural guarantee: the durable commit is an insert that the sold_seats primary key refuses for any seat already sold.

Our choice for this system: both, at different layers. The hold step (§4's Seat Hold Service) takes a pessimistic lock in Redis, because at a 40:1 contention ratio, letting every claimant proceed optimistically means 39 of every 40 buyers pick seats, enter payment details, and only then learn they lost — a worse experience than being told "that seat's gone" in the ~0.3 ms it takes Redis to refuse a SET … NX. The lock is a performance layer, not the guarantee: it lives in Redis, which can fail over and lose recent writes (§10). The guarantee is the commit in §6, an optimistic check in its simplest form — an INSERT into sold_seats, whose primary key on (event_id, seat_id) rejects a second sale of a seat however the two buyers got there. If Redis ever let two buyers hold the same seat, both reach checkout and exactly one insert succeeds; the other buyer's payment authorization is voided, so they're never charged (§6). This is the same "hot-path lock, durable-path constraint" split Airbnb-style booking uses; the difference here is how aggressively the hot path has to turn losers away, because the contention ratio is orders of magnitude higher.

Implementation sketch: all-or-nothing hold + the commit insert ›
-- Step 1: Redis Lua script — all-or-nothing hold (Seat Hold Service)
-- KEYS[1]    = "hold:{event_id}:<hold_id>"
-- KEYS[2..n] = "seat:{event_id}:<seat_id>", one per requested seat
--              (the {event_id} hash tag keeps every key in one slot, §9)
-- ARGV[1] = hold_id (derived from the request's idempotency key, so a retry reuses it)
-- ARGV[2] = buyer_id   ARGV[3] = price quote, cents   ARGV[4] = ttl_ms (480000)
for i = 2, #KEYS do
  local v = redis.call('get', KEYS[i])
  if v and v ~= ARGV[1] then
    return {'TAKEN', KEYS[i]}  -- another buyer's hold, or SOLD: claim nothing
  end
end
for i = 2, #KEYS do
  -- NX: a retry of this same hold claims nothing new and never extends the TTL
  redis.call('set', KEYS[i], ARGV[1], 'NX', 'PX', ARGV[4])
end
redis.call('hset', KEYS[1], 'buyer', ARGV[2], 'price', ARGV[3])
redis.call('pexpire', KEYS[1], ARGV[4], 'NX')  -- PEXPIRE NX: Redis 7+
return {'HELD'}

-- Step 2: Checkout Service. First: the hold key exists, its buyer matches, every
-- seat key still carries this hold_id, and payment is AUTHORIZED (not captured)
-- for the quoted price. Then one transaction — the commit point:
BEGIN;
INSERT INTO orders (order_id, event_id, buyer_id, total_cents,
                    payment_auth_id, idempotency_key, status)
VALUES ($order_id, $event_id, $buyer_id, $total, $auth_id, $idem_key, 'pending_capture');
INSERT INTO sold_seats (event_id, seat_id, order_id, price_paid_cents)
VALUES ($event_id, $seat_1, $order_id, $p1),
       ($event_id, $seat_2, $order_id, $p2);  -- one row per held seat
INSERT INTO outbox (aggregate_id, event_type, payload)
VALUES ($order_id, 'order_committed', $payload);
COMMIT;
-- unique_violation on sold_seats_pkey → ROLLBACK, void the authorization, 409.
--   The seat was already sold to someone else; this buyer is never charged.

-- Step 3: capture the authorization → UPDATE orders SET status = 'captured'.
-- Step 4: SET seat:{event_id}:<seat_id> "SOLD:<order_id>" per seat (no TTL),
--         then DEL hold:{event_id}:<hold_id>.

The Lua script is one round trip and runs atomically on Redis's single command thread, so nothing can slip in between "check every seat" and "claim every seat" — the same check-then-act problem the rate limiter's token-bucket script solves for counter increments. Checking all the seats before setting any is what makes a two-seat request all-or-nothing: a buyer never ends up holding one seat of a pair. The Postgres insert is the structural guarantee: whatever Redis believed, the primary key admits one row per seat, and a unique violation tells the Checkout Service unambiguously that it lost.

💬

One open question: what if Redis fails between a hold succeeding and checkout completing? Holds exist nowhere else, so buyers mid-checkout find their hold gone, get a 410, and hold again; the seat keys marked SOLD are reloaded from sold_seats before Seat Redis takes new holds. That's the price of keeping holds out of Postgres: a Redis failure costs some in-flight buyers their holds, and can never cost a double sale. §10 covers this failure mode in full.

05b

API design — the queue token as the interface contract

The admission token shapes this API. It's a capability rather than a lookup: the one endpoint the herd would otherwise flatten, /seats/hold, requires it, and it has to be verifiable without a network call back to the service that issued it (§4's rationale for why).

POST /waiting-room/join

Called when a buyer navigates to a high-demand event that has admission control active. Returns a queue position immediately and a client-side polling interval; no token yet. The queue position is keyed by a deterministic id derived from device and account, not allocated fresh per request, so a retried join after a timeout returns the buyer's existing queue_id instead of allocating a second position — the same double-click problem §10 solves for seat holds, but here it protects queue fairness rather than inventory.

// Request
{ "event_id": "EVT-88213", "captcha_token": "03AGdBq..." }

// Response 200 OK
{
  "queue_id": "Q-9f21ab",
  "position": 483211,
  "estimated_wait_s": 240,
  "poll_after_ms": 3000
}

GET /waiting-room/status?queue_id=…

Polled at the client-supplied interval (jittered client-side to avoid every client synchronising into a new thundering herd). Once admitted, this response carries the admission token — a short-lived, signed JWT the client attaches to every subsequent request as a bearer token. Signing means the Seat Hold Service can validate it locally, with no call back to the Waiting Room.

// Response 200 OK — still queued
{ "status": "queued", "position": 12044, "poll_after_ms": 3000 }

// Response 200 OK — admitted
{
  "status": "admitted",
  "admission_token": "eyJhbGciOi...",  // JWT, exp: now + 10 min
  "expires_at": "2026-09-17T19:32:00Z"
}

POST /seats/hold

Authorization: Bearer <admission_token> is required and validated by signature + expiry only — no database lookup. The request names specific seats or asks for a quantity of "best available" seats in a section, which the service resolves against Seat Redis (§9). Either way the seats are held all-or-nothing: a two-seat request that finds one seat taken holds neither, so no buyer is left with half a pair. Requests carry an Idempotency-Key, from which the hold_id is derived, so a retried request returns the existing hold rather than competing with it.

// Request
{ "event_id": "EVT-88213", "seat_ids": ["SEC-A-ROW-3-12", "SEC-A-ROW-3-13"] }
// or: { "event_id": "EVT-88213", "best_available": { "quantity": 2, "section": "floor" } }

// Response 201 Created
{
  "hold_id": "H-771029",
  "seat_ids": ["SEC-A-ROW-3-12", "SEC-A-ROW-3-13"],
  "price_quote": 378.00,  // stored in the hold record, locked for its duration (§5)
  "expires_at": "2026-09-17T19:40:00Z"
}

// Response 409 Conflict — lost the race; nothing was held
{ "error": "seat_unavailable", "message": "One of those seats was just claimed." }

POST /checkout

Authorized by the buyer's ordinary session plus ownership of the hold — not the admission token. Once a buyer holds seats, the hold is the capability, so a token that expires mid-checkout can't strand a buyer still inside their 8-minute hold. Requires an Idempotency-Key header: mobile clients retry on timeouts, and a retried request must never authorize or capture twice. The price charged is the price_quote stored with the hold, not whatever the dynamic-pricing service shows now, so a price change mid-checkout never surprises a buyer.

// Request — Idempotency-Key header required
{ "hold_id": "H-771029", "payment_method_id": "pm_3x91..." }

// Response 201 Created
{
  "order_id": "ORD-55123",
  "status": "confirmed",
  "seat_ids": ["SEC-A-ROW-3-12", "SEC-A-ROW-3-13"],
  "total_charged": 378.00
}

// Response 409 Conflict — lost at the commit (§6); authorization voided
{ "error": "seat_sold", "message": "That seat sold to another buyer. Your card was not charged." }

// Response 410 Gone — hold expired before checkout completed
{ "error": "hold_expired", "message": "Your hold on this seat has expired." }

Optional endpoints by level

EndpointPurposeLevel
GET /events/:id/seatmapRender the venue seat map with near-live availability (§8)L4
DELETE /seats/hold/:idBuyer voluntarily releases a hold early, freeing the seat before TTL expiryL4
POST /orders/:id/cancelCancel a confirmed order: deletes its sold_seats rows, clears the SOLD keys, refundsL4
GET /waiting-room/status (SSE variant)Server-push instead of polling, to cut redundant requests at extreme queue sizesL5
POST /internal/pricing/recomputeDynamic-pricing service pushes a new price for a section based on the Kafka demand signalL5/L6
POST /internal/admission/adjust-rateOps override to manually widen or narrow the admission rate mid-saleL7/L8
06

Core flow — from queue to confirmed seat

The dominant path through an on-sale spike, with the three branch points that matter for correctness: losing the hold race, letting a hold expire, and losing at the commit.

Core flow from waiting room to confirmed order 1. Buyer joins the waiting room 2. Admitted — admission token issued 3. View seat map — near-live availability 4. POST /seats/hold — all-or-nothing Lua TAKEN 409: seat gone, pick another 5. Hold acquired — 8 min TTL starts TTL Hold expires — seat released, back to map 6. POST /checkout — authorize payment 7. Postgres: INSERT sold_seats THE COMMIT POINT PK CONFLICT Seat already sold — void auth, 409 8. Capture, mark SOLD — confirmed
Figure 3 — The dominant flow. Everything before step 7 is reversible at no cost to the buyer; an authorization is voided, never refunded. Step 7 is the commit point: after it the sale stands, and only an explicit cancellation undoes it.
⏱️

Correctness budget: in a consistency-bound flow, the number that matters is which step is the commit point, and what's reversible before it. Steps 1–5 (queueing, admission, viewing, holding) cost nothing to undo: an expired or lost hold returns the seat to the pool with no cleanup. Step 6 authorizes the payment but moves no money, so it's undone by a void the buyer never sees on a statement. Step 7 — one transaction inserting the sold_seats rows, the order and an outbox row — is the commit point, and the sold_seats primary key (§7) is what makes committing a seat twice impossible. Step 8 captures the reserved funds only after the sale is durable, so money is never taken for a seat the buyer doesn't own.

The order — authorize, commit, capture — keeps refunds out of the conflict path. Capturing first would mean every buyer who loses at step 7 has been charged and must be refunded, which takes days to reach their statement. Committing before any payment step would sell seats to cards that then decline. Authorizing first reserves the funds without moving them — a card authorization typically lasts about seven days, far longer than an 8-minute hold — so a lost commit ends in a void and a won commit is guaranteed to be payable. The one remaining gap is a capture that fails after the commit, which is rare because the funds are already reserved; the order waits in pending_capture for a retry sweep (§10).

07

Data model

Three things are stored durably: what each seat is, which seats are sold, and the orders that bought them. Holds are deliberately absent — they live only in Seat Redis (§5). Access patterns first, since they force the schema below.

OperationFrequencyQuery shape
Read seat map for an eventVery high, but served from Redis snapshots (§8), not these tablesAll seats for event_id, with live status
Hold seats~6,000 attempts/s at peak (§3)Never reaches Postgres — Redis only (§5)
Commit a sale~25,000 orders per sale at §3's defaults, spread over the checkout windowMulti-row insert keyed on (event_id, seat_id), one transaction with the order
Look up a buyer's ordersLow, user-facing "my tickets" pagePoint query by buyer_id
Retry captures stuck in pending_captureLow, background sweep (§10)Partial-index scan on status
Rebuild Seat Redis's SOLD keysRare, recovery only (§10)Scan sold_seats by event_id

Two observations force the schema. First, the only hot-path write is the commit: a point insert keyed on (event_id, seat_id), with no range scan or join, which is what makes the primary-key guarantee cheap to enforce. Second, neither the seat-map read nor the hold burst touches Postgres, so this database is provisioned for completed sales rather than for the 6,000-attempts-a-second burst in front of them.

Sale state needs a companion table for the seat's identity — which section, row and seat number a given seat_id refers to, and its base price tier — versus its fast-moving state. The catalog is reference data, written once when an event is set up and effectively read-only afterward; it's what §4's Catalog Service loads to seed Seat Redis and render the seat map, and what a hold request's seat_id is validated against before it's even worth attempting a lock.

-- Reference data: what a seat IS, seeded once when the event is set up
CREATE TABLE event_seats (
  event_id    BIGINT      NOT NULL,
  seat_id     TEXT        NOT NULL,       -- e.g. 'SEC-A-ROW-3-12'
  section     TEXT        NOT NULL,
  row_label   TEXT        NOT NULL,
  seat_number INT         NOT NULL,
  price_tier  TEXT        NOT NULL,
  map_x       NUMERIC,                     -- seat-map render coordinates
  map_y       NUMERIC,
  PRIMARY KEY (event_id, seat_id)
);

CREATE TABLE orders (
  order_id        BIGINT      PRIMARY KEY,
  buyer_id        BIGINT      NOT NULL,
  event_id        BIGINT      NOT NULL,
  total_cents     INT         NOT NULL,
  payment_auth_id TEXT        NOT NULL,  -- gateway authorization, captured after commit
  idempotency_key TEXT        NOT NULL UNIQUE,
  status          TEXT        NOT NULL,  -- 'pending_capture' | 'captured' | 'cancelled'
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_orders_buyer ON orders (buyer_id, created_at DESC);
CREATE INDEX idx_orders_pending ON orders (created_at) WHERE status = 'pending_capture';

-- Source of truth for the zero-oversell guarantee: one row per sold seat
CREATE TABLE sold_seats (
  event_id         BIGINT      NOT NULL,
  seat_id          TEXT        NOT NULL,
  order_id         BIGINT      NOT NULL REFERENCES orders,
  price_paid_cents INT         NOT NULL,  -- the hold's quote, not today's price
  sold_at          TIMESTAMPTZ NOT NULL DEFAULT now(),
  PRIMARY KEY (event_id, seat_id),       -- the structural guarantee
  FOREIGN KEY (event_id, seat_id) REFERENCES event_seats
);

CREATE TABLE outbox (
  id           BIGINT      PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
  aggregate_id BIGINT      NOT NULL,   -- order_id
  event_type   TEXT        NOT NULL,   -- 'order_committed', 'order_cancelled'
  payload      JSONB       NOT NULL,
  created_at   TIMESTAMPTZ NOT NULL DEFAULT now()
);
Why is the primary key (event_id, seat_id), not a synthetic id? ›

The overselling guarantee has to be enforced by the database itself, not by application logic that could have a bug. Making (event_id, seat_id) the primary key of sold_seats means a second INSERT for the same seat is rejected at the constraint level — no code path, however buggy, can record two sales of one seat. Rows are only ever inserted, and deleted on cancellation, so there's no status column for a stale update to flip: "a second insert is rejected" is the whole guarantee.

Why aren't holds stored in Postgres too? ›

Writing each winning hold to Postgres would make it a second copy of state Redis already arbitrates. Converting a hold into a sale would then be an UPDATE whose correctness depends on a status guard, and every abandoned hold would leave a row for a sweep to clean up. Keeping holds only in Redis means Postgres sees one insert per sold seat, and the constraint check is a plain insert. The cost is that a Redis failure loses in-flight holds (§10): those buyers hold again, which is an inconvenience rather than an oversell.

Why record the price paid on each sold seat? ›

Dynamic pricing (§4's pricing consumer) can change a section's price between the moment a seat is held and the moment checkout completes. The hold record in Redis carries the quote from the instant it's acquired (§5), and the commit copies it into price_paid_cents, so the buyer is charged what they saw and a partial cancellation knows what each seat cost — the same pattern as the price_quote_id field in Airbnb-style booking's booking request.

08

Write-path optimisation — pacing admission and absorbing the spike

The catalog is an ordinary cold read path, and the one hot read — the seat map — is covered at the end of this section. The main optimisation target is the write side: turning an unbounded, synchronised burst of demand into a stream every downstream component is provisioned for.

Admission pacing is a token bucket, reused

The Waiting Room's admission rate (§3, §5b) is implemented with the token-bucket mechanism from rate limiter design, keyed per event instead of per user: tokens refill at the configured admission rate, and each refill tick admits the next batch of queued buyers in strict position order. The difference from a standard rate limiter is intent — there, the bucket protects the system from abuse; here, it protects the system from its own legitimate traffic, and a full bucket means "let more people in," not "reject this request."

Why admission comes in batches

A buyer only learns they've been admitted when their client next polls, every ~3 s (§5b), so admitting continuously gains nothing over admitting in batches at that cadence. Batching every 2–5 s (§2's admission-latency NFR sets the window) turns admission into one write per batch rather than one per buyer: advance the event's admitted-up-to position by ~6,000 (2,000/s × 3 s), and each status poll compares the buyer's queue position against that single counter before issuing a token.

Admission rate: fixed from §3, with a manual override

The admission rate is set before the sale from §3's arithmetic — the rate Seat Redis and checkout are provisioned for — and operators can change it mid-sale through §5b's override endpoint while watching Seat Redis p99 and checkout error rates. At this article's numbers one Seat Redis primary runs at a small fraction of its capacity (§9), so a fixed rate with a human override is enough.

When the override becomes an automatic control loop L7 aside ›

If the margin were thin — many on-sales sharing capacity, or a hold path much more expensive than one Lua script — the override becomes a loop: the Seat Hold Service reports Redis p99 and checkout's commit latency, and the Waiting Room narrows admission when either crosses a threshold and widens it as they recover. The loop needs hysteresis and a floor on the rate, or it oscillates and can starve the queue entirely. That operating risk is why it isn't the default.

Connection pooling for the commit path

Postgres sees only commits — ~25,000 orders per sale at §3's defaults, spread over the minutes buyers take to check out. A transaction-mode connection pooler (PgBouncer) keeps the backend connection count bounded however many Checkout Service instances are running, so a cluster of simultaneous checkouts queues briefly in the pooler instead of exhausting Postgres's connection limit before its throughput.

Keeping the seat map fresh without reading Seat Redis per viewer

Every admitted buyer loads the seat map, and it goes stale the moment seats start going. Reading 50,000 keys per page view would put that load on the store that's busy arbitrating holds. Instead, a snapshotter reads each section's state from a Seat Redis replica once a second into a bitmap — one bit per seat, available or not, about 6 KB for the whole venue — served from a CDN-cacheable endpoint, with per-second deltas pushed to open pages over server-sent events. A buyer can still click a seat that went a second ago; the hold returns 409 in under a millisecond and the map repaints. The map is advisory, and the hold is the only check that counts. Clients retrying after a 409 add random jitter, so losers from the same batch don't re-collide in lockstep.

💡

Why not add Postgres read replicas, the usual first move for a database under load? Because the load here is a modest stream of commit inserts, not reads, and the one hot read — the seat map — comes from Redis snapshots. A synchronous standby already exists for durability (§2's RPO = 0); more replicas would add replication work to the primary during the sale and take no load off it.

09

Deep-dive scalability — one Seat Redis per on-sale, and the waiting room

Everything contended in this system fits on one Redis primary. At §3's defaults that's 50,000 seat keys and ~6,000 hold scripts a second, each a handful of commands; one primary sustains on the order of 100,000 simple commands a second on a single core, so the hold path uses a fraction of it with room for several times §3's attempt rate. The design runs one Seat Redis primary per on-sale, plus a replica for failover, and keys every seat as seat:{event_id}:<seat_id>. The {event_id} hash tag puts all of an event's keys in one Redis Cluster slot, which is what lets a multi-seat hold script touch several seats atomically — Redis rejects a script whose keys span slots.

"Best available" runs inside that same primary as one script. Most flash-sale buyers ask for "2 tickets, best available" rather than picking a seat, and the obvious implementation — rank the seats in the service, then try to hold the top pair — sends every buyer at the same few premium seats, where all but one lose and retry. Instead, each section's free seats sit in a sorted set scored by seat quality, and the hold script pops the best N and claims them in the same atomic step, so two buyers asking at the same instant get different seats rather than colliding. A seat whose hold expires goes back into its set, driven by a second sorted set ordered by hold expiry.

The component that needs real scaling work is the waiting room, because it sees all 2,000,000 buyers rather than the admitted few thousand per second. Polling every ~3 s, the queue generates on the order of 670,000 status checks a second — by far the largest raw request volume in the system — while the seat store handles about a hundredth of that. Geo-distribution is deliberately avoided for the hot path: one event is one small, contended keyspace, and spreading it across regions would add consensus latency to every hold.

Big on-sales also overlap — a stadium tour and an arena show can open the same Friday morning. Each on-sale gets its own admission bucket and its own Seat Redis primary, provisioned ahead of time (on-sales are scheduled weeks out, so this is planning rather than autoscaling). The point is isolation: one sale's backlogged waiting room or busy Seat Redis must not slow down a second sale.

If one event outgrew one Redis primary: sharding seats and the hot-shard problem L6+ aside ›

Nothing at this article's numbers needs this, but an interviewer may push on it: what if one event needed ~100,000 hold scripts a second or more — a far bigger inventory, or a much higher admission rate? Then seats shard across several Redis primaries, and section is the natural split, because a multi-seat hold stays within one section. The hash tag becomes {event_id:section}, so each section is its own slot.

Best-available requests concentrate onto one Redis shard Seat Hold Service Shard 1 — Floor / Front ~70% of best-available traffic Hot shard Shard 2 — Mid Tier ~20% of traffic Warm Shard 3 — Upper / Lawn ~10% of traffic Mostly idle
Figure 4 — Sectional sharding distributes exact-seat traffic evenly, but "best available" resolution still concentrates load on whichever shard owns the top-ranked sections.

Sharding exposes a problem exact-seat picks don't have: "best available" concentrates on whichever shard owns the top-ranked sections, right up until their inventory runs out. Two mitigations apply. Sub-partition a hot section (front-left, front-centre, front-right as separate slots) so "best available in the front section" isn't one physical shard. Serve ranking reads from replicas so they don't compete with hold scripts on the primary.

TradeoffSub-partitioning only pays off for a section that's provably hot. Applied to every section, it adds operational complexity where there was never contention.
Scaling the waiting room itself ›

At 2,000,000 concurrent buyers, even a lightweight polling endpoint (§5b) receives on the order of 2 million status checks within a single 3-second polling interval — roughly 670,000 a second, effectively the full waiting-room population, since only ~25,000 of 2,000,000 buyers are admitted before the sale sells out (§3). Admission Redis is sharded by queue_id hash, which is safe in a way seat sharding isn't: a buyer's queue position has no relationship to any other buyer's, so nothing concentrates. The admitted-up-to counter (§8) is one key per event, read on every poll, so the Waiting Room caches it in-process for a fraction of a second rather than reading Redis 670,000 times a second. The larger cost is connection fan-out at the API Gateway layer; a server-sent-events variant (§5b's optional endpoint) trades a slightly more complex client for far fewer redundant polling round trips at this scale.

Geo-distribution ›

Unlike a globally distributed system, one event has one venue and one on-sale instant — there's no natural geographic partition of the workload the way there is for, say, a booking system with listings worldwide. The pragmatic approach is to run the hot path (Waiting Room, Seat Hold, Checkout) for a given on-sale event in a single region close to the majority of expected demand, accepting higher latency for a minority of buyers, rather than attempting cross-region coordination on a keyspace this small and this contended — cross-region consensus on a single 50,000-row hot table would be strictly worse than the latency cost it's trying to avoid.

10

Failure modes and edge cases

One principle runs through every row: Redis can be wrong, Postgres can't. A failure in front of the commit may cost a buyer a hold or a retry; none may produce two sales of one seat, or a charge without a seat.

ScenarioProblemSolutionLevel
Buyer double-clicks "hold" Two identical hold requests race each other Both carry one Idempotency-Key, hence one hold_id; the Lua script (§5) treats that hold's seats as already its own L4
Seat Redis primary fails mid-sale Holds live only here; the newest SOLD keys may be lost too Buyers hold again. Reload SOLD keys from sold_seats before taking holds; the primary key catches anything missed L5
Two buyers reach checkout holding the same seat A Redis failover, or a hold that expired mid-checkout and was re-held The second sold_seats insert fails the primary key: roll back, void that authorization, return 409. Nobody is charged L5/L6
Payment gateway times out on authorize or capture The outcome is unknown; a blind retry could charge twice Retry with the same gateway idempotency key, which returns the original result rather than acting twice L5/L6
Capture fails after the commit The seat is sold but no money has moved The order stays pending_capture; a sweep retries within the ~7-day authorization, and cancels the order if declined L6
Order DB primary fails Asynchronous replication could drop sales committed just before failover Synchronous replication: a commit returns only once the standby has it, so failover loses nothing (RPO = 0) L6
Clock skew between token issuer and verifier Valid admission tokens rejected, or expired ones accepted NTP-synced hosts plus ~5 s leeway on JWT expiry; server-relative TTLs everywhere else L6
The on-sale instant is a scheduled surge Autoscaling reacts in minutes; this goes from zero to peak in under a second Pre-warm Seat Redis, Checkout replicas and the Postgres pool before the scheduled time L7/L8
Why reload SOLD keys if the primary key already prevents oversell? ›

Correctness doesn't need the reload; the buyer experience does. A replica promoted after a failover may be missing SOLD keys written in the last moments before it (Redis replicates asynchronously). Without the reload, those sold seats look free: buyers hold them, fill in payment details, and only learn at checkout that the seat was gone — a 409 where §5's design promises a sub-millisecond one at hold time. Loading sold_seats for the event before the new primary accepts holds takes a single scan of at most 50,000 rows. In-flight holds can't be recovered the same way, because they were never written anywhere else; that's the cost §7 accepted for keeping holds out of Postgres.

10b

Abuse, trust and compliance

The threats here are dominated by two things: this is a money-movement system (fraud, chargebacks), and it's also a scarce-resource allocation system that automated buyers have a strong financial incentive to game (scalping). Five threats, each with a headline defense: queue-stuffing bots → CAPTCHA/proof-of-work plus per-account velocity limits at admission; hold hoarding → a per-account hold cap enforced inside the hold script; admission-token theft or forgery → signed, event-scoped, short-lived tokens; payment fraud and chargebacks → idempotency keys and velocity checks before authorization; price scraping → a rate-limited price endpoint that never exposes demand signals. The reasoning and interview probes for each are in the accordions. Entry validation at the venue — rotating barcodes, scanner redemption — is a separate system with its own store, out of scope here.

Bot and scalper defense at the admission gate ›

The cheapest place to stop a scalping bot is before it ever consumes a queue position. POST /waiting-room/join (§5b) requires a passed CAPTCHA or proof-of-work challenge before issuing a queue position. Per-account and per-device velocity limits (INCR joins:{account_id}:{event_id} with a TTL matching the sale window, rejecting past a small cap like 4 joins) stop one identity from occupying dozens of queue slots. Purchase-quantity limits are enforced again at checkout (max N tickets per account per event), independent of admission, so a bot that gets past the gate is still capped downstream.

Interview probe (L5): "How do you stop a script from generating 10,000 queue positions?" — the answer has to name a specific identity-binding mechanism (device fingerprint, verified account, CAPTCHA), not just "rate limit it," since a naive IP-based rate limit is trivially defeated by rotating IPs.

Hold hoarding ›

A hold costs nothing, so a bot with admitted accounts can hold seats it never means to buy, keeping them off the market 8 minutes at a time and re-holding as they expire — a way to push buyers toward resale listings. Three cheap defenses. A per-account cap on seats held at once, checked inside the hold script itself: one more key, holds:{event_id}:<account_id>, compared and incremented in the same atomic step as the claim, so parallel requests can't race past it. A short re-hold cooldown on a seat an account just let expire. And the hold-to-purchase ratio per account, fed to scalper detection, since real buyers convert most holds and hoarders convert almost none.

Interview probe (L6): "What stops one person holding the whole floor without paying?" — the expected answer puts the cap inside the atomic hold rather than in a separate check that parallel requests can race, and notes that shortening the TTL alone hurts real buyers more than bots.

Admission-token forgery and replay ›

The admission token (§5b) is a bearer credential that grants access to the hot path — if it leaked or could be forged, it would defeat the entire admission gate. It's signed (so it can't be forged without the signing key), scoped to one event_id and one queue_id (so it can't be replayed against a different sale), and short-lived (so a leaked token has a small blast-radius window). A stolen token is a real risk (screen-shared or sold on secondary markets) but is bounded: it grants queue-skip access to one sale for a few minutes, not standing access to the system.

Interview probe (L6): "Who is trusted with a valid admission token, and what happens when that trust is abused?" — the expected answer distinguishes "the token proves you waited your turn" from "the token proves who you are," and recognises the system doesn't need the second guarantee until checkout, where the buyer's session and payment identity authenticate them.

Payment fraud and chargebacks ›

This is the same problem the payment processing post covers in depth — idempotency keys prevent double-charges, and card-testing/velocity fraud checks run before authorization. The ticketing-specific addition is that a chargeback also has to delete the sale's sold_seats rows, releasing the seat back into inventory (or, if the event already happened, it doesn't — that's a business rule, not a systems one) and is flagged for the scalper-detection pipeline, since chargeback rate per account is a strong bot signal.

Dynamic-pricing scraping and manipulation ›

A price-lookup endpoint that's cheap to call and reveals the live pricing algorithm's internals invites two abuses: scraping the full pricing surface to arbitrage resale pricing, and hammering the endpoint hard enough to affect the demand signal the algorithm itself consumes (§4's Kafka-driven pricing consumer). Price checks are rate-limited per identity like any other endpoint, and the response only ever returns a current price, never the demand signal, trend data, or ranking internals that would let a scraper reverse-engineer the model.

Interview probe (L7): "How would you detect systemic price-scraping across the whole population, not just one abusive account?" — the expected answer reaches for aggregate anomaly detection on the pricing-endpoint access pattern (e.g. a stream job over the Kafka access log flagging accounts or IP ranges with abnormal call-to-purchase ratios), not a per-request check.

Compliance and retention ›

Routing card data through the external payment gateway (§4) rather than handling it directly keeps this system out of full-scope PCI-DSS — it qualifies for the lightest self-assessment tier (SAQ-A) because cardholder data never touches its own stores. Order and buyer-PII records are retained for a fixed window (years, for tax and chargeback-dispute purposes); a deletion request is handled at the identity-service layer, out of scope for this article but named here so it's clear it wasn't overlooked.

11

How to answer by level

The mechanisms don't change much across levels — what changes is whether they're composed into one coherent contention story or reached for independently.

L4 ›
What good looks like
  • A schema for events, seats and orders, and a working hold → checkout flow with an explicit TTL on the hold
  • Recognises that "book a seat" needs a lock or constraint at all — doesn't just write and hope
What's still missing for the next level
  • Names no specific mechanism that makes a double sale impossible
  • Doesn't yet reason about what happens when 2 million people call the same endpoint at once
L5 ›
What good looks like
  • Identifies the thundering-herd problem unprompted and proposes a queue or rate limit for admission
  • Proposes an atomic lock (Redis SET NX PX or equivalent) for the seat-hold step, with a TTL
  • Backs the lock with a unique key on (event_id, seat_id) in the database
  • Knows a checkout endpoint needs an idempotency key
What's still missing for the next level
  • Has both mechanisms but can't yet say which one is load-bearing, or what happens if the lock's store fails
L6 ›
What good looks like
  • Separates the fast, non-durable contention gate (Redis) from the durable commit point (Postgres), and can say which failures each layer tolerates
  • Orders payment around the commit — authorize, commit, capture — and says what a buyer who loses at the commit experiences
  • Holds multi-seat requests all-or-nothing, and resolves "best available" without every buyer colliding on the same seats
What's still missing for the next level
  • Solves each failure mode in isolation rather than as instances of one underlying design principle
L7/L8 ›
What good looks like
  • Frames the whole system around one principle — keep contested work off the durable store — and derives every other component from it
  • Treats the on-sale instant as a planned capacity event requiring pre-warming, not something reactive autoscaling can handle
  • Treats the admission rate as an operational dial the team owns across on-sales: set from the previous sale's measured Redis p99 and commit throughput, rehearsed with a load test before a major on-sale, with a named owner allowed to change it live
  • Reasons about the business tradeoff between strict fairness, seat-picking UX, and system load as a genuine three-way tension
What distinguishes L7/L8
  • Prices each guarantee: synchronous replication adds a cross-AZ round trip (~0.5–1 ms) to every commit, and the answer explains why that's cheap against a lost sale — and what the business would accept by relaxing it

Classic probes, level-differentiated answers

QuestionL4L5L6L7/L8
"Two buyers click 'buy' on the same seat at the same millisecond. Walk me through exactly what happens." The database should reject the second write — doesn't yet name a specific mechanism Redis SET NX rejects the loser in under a millisecond; a unique key on (event_id, seat_id) backs it up Says which is load-bearing: the lock is a performance layer, the sold_seats insert is the guarantee, and a loser at commit gets a voided authorization, not a refund Ties the split to §3's numbers — ~6,000 hold attempts a second never reach Postgres, which sees ~25,000 inserts per sale — and argues what a Postgres-only design would cost at 40:1 contention
"How would you change this design for a system that could tolerate a small oversell, like a flight?" Keeps the same lock; unsure what would change if oversell were acceptable Knows airlines overbook on purpose, and that the per-seat key would become a count checked against a limit Recognises that relaxing zero-oversell (§2) would let §5's pessimistic lock go in favour of pure optimistic checking, trading a rejected-at-commit UX for a simpler hot path Quantifies it: the oversell rate at which dropping Redis outweighs a worse rejected-at-commit UX, tied to the business's risk tolerance rather than a fixed threshold
"The Waiting Room's Redis cluster goes down entirely during a sale. What happens?" Says the sale breaks — doesn't separate what stops from what keeps working New admissions stop; buyers already admitted can keep holding and checking out Separates correctness from availability: Seat Redis and Postgres don't depend on Admission Redis, so holds and checkouts in flight are unaffected, and admission resumes on recovery Also reasons about recovery: resuming admission without re-admitting buyers who hold a valid token, and whether failover changes fairness for buyers mid-queue
"How do you decide the admission rate number itself?" Picks a number without deriving it from anything in the system Derives it from §3's capacity numbers as a fixed calculation Derives it from what Seat Redis and checkout are provisioned for, and names the signals — Redis p99, checkout errors — that would justify overriding it mid-sale Treats it as an operational dial: set from the last sale's measurements, rehearsed under load, with a named owner — and knows when an automatic loop would earn its hysteresis (§8)
How the pieces connect
Tracing each requirement to the decision it forced
1
Zero structural overselling (§2)→the commit point is the sold_seats insert, placed between payment authorization and capture (§6)→a primary key on (event_id, seat_id) is the guarantee, whatever Redis believed (§7)
2
2 M buyers against 50 K seats (§3)→~6,000 hold attempts a second would swamp a relational lock manager→holds live only in Redis, and Postgres sees only the ~25,000 winning orders (§4, §5, §7)
3
Thundering herd at the on-sale instant (§3)→a virtual waiting room paces admission into the hot path (§4, §5b)→it reuses the token bucket from rate limiter design, admitting in batches matched to the poll interval (§8)
4
Two-seat and "best available" requests (§5b)→no buyer may end up with half a pair, and buyers mustn't all collide on the same top seats→one atomic script claims every seat or none, resolving "best available" inside Seat Redis in the same step (§5, §9)
5
No committed sale lost on failover (§2)→Redis may lose holds, Postgres may not lose sales→synchronous replication on the Order DB, and SOLD keys rebuilt from sold_seats after a Redis failure (§10)
6
Admission rate derived from §3→at that rate one Seat Redis primary runs well under capacity (§9)→a fixed rate with a manual override is enough, and an automatic backpressure loop is an L7 refinement (§8)

System Design Mock Interviews

AI-powered system design practice with real-time feedback on your architecture and tradeoff reasoning.

Coming Soon

Practice Coding Interviews Now

Get instant feedback on your approach, communication, and code — powered by AI.

Start a Coding Mock Interview →
Also in this series