Selling 100k Tickets Without Overselling
A World Cup on-sale is a contended finite inventory: don't sell seat #1,000 twice. A runnable Laravel lab measuring naive (oversells 400%) vs pessimistic vs atomic decrements, plus seat holds, idempotent payment, a waiting room, and two AWS designs (on-demand + serverless).
Selling World Cup or concert tickets is the purest form of a contended, finite inventory: 100,000 people hit buy in the same second for 1,000 seats, and selling seat #1,000 twice is a refund, a support ticket, and a reputational hit. The interesting decisions aren't in the framework — they're in how you serialize the decrement, how you protect the backend from the stampede, and how you stay correct when clients retry. Here it is, built in Laravel and measured under real concurrency.
Runnable companion:
high-demand-ticketingon GitHub.make benchreproduces every number below (Laravel + MySQL + Redis in Docker; the load is real forked processes, not mocks).
The core: four ways to sell the last ticket
500 concurrent buyers, 100 tickets. How many actually got sold?
strategy capacity sold OVERSOLD time buys/s
------------------------------------------------------------------
naive 100 500 400 3.52s 142
pessimistic 100 100 0 2.59s 193
atomicDb 100 100 0 2.16s 232
atomicRedis 100 100 0 1.40s 356
The naive flow reads the count, checks it's positive, then decrements — three steps with a gap. Two buyers both read remaining = 1, both pass the > 0 check, both decrement: the counter goes to -1 and two tickets are sold for one seat. Under 500 buyers it oversold by 400 tickets. This is a check-then-act race, and it fails silently — the code "looks correct".
The fix is to make the check and the take one atomic operation:
- Pessimistic lock (
SELECT … FOR UPDATE) — serializes every buyer through a row lock. Correct, but throughput is bounded by lock hold time. - Atomic SQL (
UPDATE … SET remaining = remaining - 1 WHERE remaining > 0) — the DB fuses the check and the write; exactlycapacityupdates ever succeed. Simple, correct, no explicit lock. - Atomic Redis (
DECR) — one op on a single-threaded server; fastest, with the durable order still written to SQL.
Never read-then-write a contended counter. Redis is fastest; the conditional SQL UPDATE is the simplest thing that's still correct.
Holds, idempotent payment, and a waiting room
Three more pieces a real sale needs:
- Seat holds reserve a seat during checkout via the same atomic decrement, with a TTL; a reaper returns unpaid holds so an abandoned cart never leaks a seat.
- Idempotent payment keys each payment on a client key. Delivered 5× (client retries + webhook redelivery) → 1 order, 1 charge. The same key flows through to the gateway (Stripe's
Idempotency-Keyheader) so the gateway dedupes the charge too — idempotency end to end, not just in your database. And the gateway call stays outside the DB transaction: never hold a row lock across a slow network call. - A virtual waiting room caps how many buyers reach the counter. Measured — 800 arrivals, gate of 50:
admission peak concurrency sold oversold
no waiting room (direct) 800 100 0
waiting room (gate = 50) 50 100 0
Peak DB concurrency dropped 800 → 50 with zero change to correctness. The waiting room protects the backend; the counter protects the count. Different problems — at scale you want both.
Surviving the gap between Redis and the database
Redis decides who gets a seat (DECR), then the order is written to the database — so what if that write fails after Redis already sold the seat? Three layers close the gap: a synchronous compensate (INCR the seat back on error), a durable, idempotent, self-compensating queue job for the order write (it retries through a brief DB outage, and returns the seat if it truly can't write), and a scheduled reconciliation that re-derives remaining = capacity − confirmed − holds from the database — which catches the one case the others can't: a crash between the DECR and the write. Measured: 5 seats stranded by simulated crashes, all 5 returned. The database is the source of truth; Redis is a fast projection you can always rebuild — so losing Redis is recoverable, not data loss. (Don't need Redis-level throughput? The conditional SQL UPDATE does the decrement and the order in one transaction and removes this gap entirely.)
Taking it to AWS: on-demand and serverless
The same invariants map onto two AWS topologies:
- On-demand — CloudFront + WAF → waiting room → ALB → ECS Fargate (Laravel), atomic counter in ElastiCache Redis (
DECR), durable orders in Aurora MySQL, SQS workers, EventBridge reaper. Warm, predictable latency. - Serverless — CloudFront → API Gateway → Lambda, with the atomic decrement as a DynamoDB conditional
UpdateItem(remaining > 0), seat holds as DynamoDB items with TTL (Streams return the seat on expiry — expiry is free, no cron), idempotent payment as a conditionalPutItem. Scales to zero for spiky sales.
The serverless trap is the hot partition: one inventory item takes every write and DynamoDB throttles it. The fix is the same hot-key split from the sharding lab — shard the counter into N sub-items and sum them, trading an exact real-time count for write throughput. (Full AWS diagrams + trade-offs.)
What I'd say in an interview
- A contended finite inventory is a check-then-act race — fix it with one atomic operation (conditional
UPDATE, row lock, or Redis atomic), never read-then-write. Measured: naive oversells 400%, atomic sells exactly capacity. - A waiting room is admission control, not correctness — it bounds peak load; the counter decides who wins. You want both.
- Holds need a TTL + reaper (or DynamoDB TTL) so abandoned checkouts don't leak seats; payment must be idempotent end to end — down to the gateway's own idempotency key.
- Splitting the counter (Redis) from the truth (DB) buys throughput but opens a gap — close it with a durable retrying write, compensation, and reconciliation; the counter is always rebuildable from the durable ledger.
- On AWS: Redis
DECR/ Aurora conditional update (on-demand) or DynamoDB conditional write + TTL (serverless) — same correctness model, different runtime.