"Ticketmaster for a hot show: 100,000 fans rush for 10,000 seats the second sales open. No double-booked seats, no overselling, and the site can't fall over."
"Showing '10 seats left' means 10 people can each grab one." No — that number was true when the page loaded, and 40,000 people loaded the same page. Every one of them sees "10 left," and without coordination all 40,000 will try to take them. Availability displayed is not availability reserved. The only number that matters is the one decremented atomically at the moment of booking.
| Assumption | Value |
|---|---|
| Fans in first 60s | 100,000 |
| Arrival rate | 100k ÷ 60 ≈ 1,700 requests/sec sustained burst |
| Seats | 10,000 |
| Hold TTL (time to pay) | 10 minutes |
| Payment completion rate | ~70% — so ~3,000 seats get released back |
| Queue drain | 10k seats ÷ 1.7k users/sec ≈ 6 seconds to assign every seat |
The burst is short and brutal: the system needs to serialize 1.7k competing writers/sec for about a minute. That's trivial for a single atomic counter — and impossible to get right with read-then-write.
A malicious user could hold 4 seats × 10 min repeatedly, keeping inventory locked without paying. Defenses: per-user hold limits (e.g. 4 seats), holds that don't renew, and requiring a verified account + payment method on file before entering the queue. The TTL isn't just UX — it's inventory protection.
The centerpiece: a queue in front, an atomic counter at the core.
flowchart TD
A[100k fans] --> B[CDN + static sale page]
B --> C[Queue service
hands out numbered tokens]
C --> D[Booking API
validates token order]
D --> E[(Seat inventory
atomic decrement)]
D --> F[Payment service]
E --> G[(Orders DB)]
F --> G
H[Hold sweeper
releases expired TTL holds] --> E
I[Waitlist service] --> D
Think of it like a bakery on cronut day: the queue service hands out numbered tickets at the door (fairness), the counter only serves ticket #1, then #2 (order), and the tray of cronuts is counted by one person with a clicker — atomic decrement. Nobody reaches behind the counter to grab one themselves.
sequenceDiagram
participant U as Fan (token #42)
participant B as Booking API
participant S as Seat inventory (Redis)
participant P as Payment
U->>B: POST /events/e9/book {seat: A3, token: 42}
B->>B: token #42 is now serving? yes
B->>S: EVAL reserve.lua (check + decrement + TTL hold)
Note over S: Lua runs atomically:
if seats_left > 0 then
seats_left-- ; hold seat 10 min
S-->>B: OK seat A3 held
B->>P: charge card (async, 10 min window)
P-->>B: paid
B->>S: confirm hold → booked
B-->>U: 200 ticket confirmed
The Lua script is the hero: check-and-decrement happens inside Redis as one indivisible step. Two requests for the last seat arrive — Redis runs the script twice, the second sees zero left. No lock service, no distributed transaction.
sequenceDiagram
participant U as Fan
participant Q as Queue service
participant B as Booking API
U->>Q: GET /events/e9/queue-token
Q-->>U: {token: 84,213, ahead: 74,212}
Note over U: browser polls position,
shows "you are #74,212"
Q->>B: admit next 500 tokens
B-->>U: your turn — 2 min to book
The queue absorbs the 100k burst so the booking API only ever serves a controlled trickle. Tokens are signed (can't be forged), and position polling is cheap and cacheable.
GET /v1/events/{id}/queue-token → { token, position, etaSec }
GET /v1/events/{id}/seats → seat map (cached 5s, labeled "as of")
POST /v1/events/{id}/book → { seatId, token } → { holdId, expiresAt }
POST /v1/holds/{holdId}/confirm → { ticketId } (after payment)
POST /v1/holds/{holdId}/release → 204 (user cancels / TTL)
HSET event:e9:seats A3 → {status:held|booked, holdId, ttl} + DECR-able counter event:e9:left. Per-seat hashes give the seat map; the counter gives the fast "sold out?" check.tickets(id, event_id, seat_id, user_id, status, hold_expires_at) with a unique constraint on (event_id, seat_id) where status='booked' as the backstop. Even if every cache lies, the database refuses the double-booking.book takes an idempotency key — retried requests (user double-tapped) never create two holds.| Decision | Option A | Option B | Pick |
|---|---|---|---|
| Seat selection | User picks exact seat | System assigns best available | B for hot sales (one counter, no contention on seat A3); A only for low-demand shows |
| Locking | Pessimistic lock per seat | Atomic decrement + TTL hold | B — no lock manager to fail, TTL auto-heals abandoned carts |
| Queue | Virtual waiting room | Let everyone hammer the API | A — the queue is the load shedding; without it the burst hits the DB directly |
| Consistency | Strong everywhere | Eventual for seat map display | Split: strong at booking (Redis+DB constraint), eventual for the displayed map (5s cache, labeled "as of") |
Cloudflare Waiting Room (or a small Redis-backed token service) for the queue — don't hand-roll fairness. Booking API in Go, inventory in Redis with the reserve Lua script, orders in Postgres with the unique booked-seat constraint as the final backstop. Payments via Stripe with idempotency keys end to end. Seat map served from a 5-second CDN cache stamped "as of HH:MM:SS" so nobody mistakes it for a reservation. A cron sweeper releases expired holds every 30s and nudges the waitlist. Boring parts, sharp edges.
General admission (just a count) is the easy mode: one atomic counter, no seat map. Assigned seating adds per-seat contention — hot seats (front row) get hammered. The pro move: partition the counter per section (orchestra_left, balcony...) so contention spreads, and let users pick a section rather than an exact seat during the burst.
100 fans (dots) race for 10 seats. First run it without locking — every fan reads the seat map once, then "books" what looked free. Then reset and run it with queue tokens + atomic decrement.
💡 The chaos mode simulates the classic bug: if (seat.free) seat.book(user) executed by 100 clients against a stale read. The clean mode simulates one Lua script per request — check, decrement, and hold as a single atomic step, served in queue-token order.