September 5, 2026
· 13 min read2 Million Users, 500 Seats, 10:00 AM — Designing a Flash-Sale Ticketing System That Never Double-Books
At 10:00:00 sharp, two million people hit the same endpoint for five hundred seats. Your database is not the answer — it's the casualty. This is a full breakdown of the admission control layer, the atomic inventory counter, the hold lifecycle, and the payment reconciliation edge cases that actually cause double bookings in production.

TL;DR
- Never let 2M requests reach your database. Reject ~97% at the edge with a signed-token virtual waiting room before the app tier sees them.
- Seat inventory lives in a Redis integer counter, mutated by a single Lua script that also enforces one-hold-per-user. Redis is single-threaded, so the decrement is atomic by construction.
- A successful decrement grants a hold, not a seat. Holds are dual-written: Redis key with TTL (fast) plus a Postgres row marked
pending(durable). - Postgres is the ledger of record. Redis is a cache of derived state. If they disagree, Postgres wins.
- Payment reconciliation is where real double-bookings come from: non-idempotent webhooks, expired-hold-then-payment-succeeds races, and reapers that release inventory twice.
- Reserve a 2% overflow buffer (10 of 500 seats) to absorb late payment confirmations without refunding a paying customer.
Why this problem breaks normal architectures
The contention ratio is what makes this hard. 2,000,000 users for 500 seats is 4000:1. Compare that to a typical e-commerce checkout, where inventory is in the thousands and traffic arrives over hours.
Here, everything arrives inside a 3-second window. Users pre-load the page, some of them run scripts, and browsers retry aggressively on timeout — so your actual peak request count is higher than your user count, often 1.5–2x.
The naive design looks like this:
BEGIN;
SELECT * FROM seats WHERE event_id = 42 AND status = 'available'
LIMIT 1 FOR UPDATE SKIP LOCKED;
UPDATE seats SET status = 'held', user_id = $1 WHERE id = $2;
COMMIT;This is correct. It will never double-book. It will also take your site down.
Here's the catch: every one of those 2M requests needs a database connection. Postgres handles maybe 500 concurrent connections comfortably (a few thousand with PgBouncer). At 2M concurrent attempts you have a queue depth of 4000x your capacity, connection acquisition timeouts fire, retries pile on, and the whole thing collapses into a retry storm before a single seat is sold.
Important: Correctness and scale are separate problems here. Solve scale by rejecting traffic early, and correctness by serializing the tiny surviving fraction.
The architecture
Four layers, each one reducing traffic by roughly an order of magnitude before the next.
Notice what's not here: no distributed lock manager, no consensus protocol, no Kafka in the allocation path. The seat count is small enough that a single Redis node is both the fastest and the simplest correct answer.
Layer 1 — Admission control
The single biggest win in this entire design is that 1.95 million people never touch your application.
The static sold-out flag
The moment the counter hits zero, publish a flag to the CDN edge. Every subsequent request gets a cached 410 Gone served from the edge POP in ~15ms, at zero cost to your origin.
// After the counter drains, invalidate and republish the edge flag.
if ($remaining === 0) {
Cache::store('cloudflare_kv')->put("event:{$eventId}:sold_out", true);
}The virtual waiting room
Before the sale opens, every user who lands on the page gets a signed token containing their queue position and a join timestamp. No server-side session, no database row — just an HMAC.
$payload = [
'user_id' => $user->id,
'event_id' => $eventId,
'position' => $position, // atomic Redis INCR at join time
'joined_at' => now()->timestamp,
];
$encoded = json_encode($payload);
$token = base64_encode($encoded) . '.' .
hash_hmac('sha256', $encoded, config('app.queue_secret'));Breaking it down:
positioncomes from a singleINCR queue:{event_id}:counter— one Redis op per user, ~50k ops/sec per node, trivially shardable.- The HMAC means the edge can validate a token without a database or Redis lookup. Tampering with your position is cryptographically blocked.
joined_atgives you FIFO fairness, which matters legally in some jurisdictions.
Admission is then just a threshold check at the edge:
admitted_watermark = 5000 // published to edge KV, incremented as seats drain
if token.position > admitted_watermark: hold at waiting room
if token.position > 50000: reject immediately — sold outYou admit 10x your seat count at a time. 5,000 admitted users compete for 500 seats, the counter drains in under two seconds, and you raise the watermark only if seats remain (which they will, because some admitted users abandon).
Anyone beyond position 50,000 gets an honest "this is sold out" instead of a false hope and a 30-second spinner.
💡 Tip: Show the estimated wait time, not just the position. "You are #12,400 of 2,000,000" causes abandonment. "About 4 minutes" does not.
Layer 2 — Atomic inventory
Now only ~5,000 concurrent requests reach the app tier. This is where the actual allocation happens, and it must be a single atomic operation.
Redis executes Lua scripts atomically — no other command interleaves. So we do the contention check, the duplicate-user check, and the decrement in one round trip.
-- KEYS[1] = seats:{event_id} integer counter
-- KEYS[2] = user_holds:{event_id} hash user_id -> hold_id
-- KEYS[3] = hold_expiry:{event_id} zset score = expires_at_unix
-- ARGV[1] = user_id
-- ARGV[2] = hold_id (UUID generated by the app)
-- ARGV[3] = hold_ttl_seconds
-- ARGV[4] = expires_at_unix
-- One hold per user. Idempotent retries return the same hold.
local existing = redis.call('HGET', KEYS[2], ARGV[1])
if existing then
return {1, 'ALREADY_HELD', existing}
end
local remaining = tonumber(redis.call('GET', KEYS[1]))
if remaining == nil or remaining <= 0 then
return {0, 'SOLD_OUT', ''}
end
redis.call('DECR', KEYS[1])
redis.call('HSET', KEYS[2], ARGV[1], ARGV[2])
redis.call('SET', 'hold:' .. ARGV[2], ARGV[1], 'EX', ARGV[3])
redis.call('ZADD', KEYS[3], ARGV[4], ARGV[2])
return {1, 'HELD', ARGV[2]}Breaking it down:
- The
HGETguard is your bot defence and your retry safety net in one line. A user who fires 200 parallel requests gets exactly one hold, and every duplicate returns the samehold_id— so a client retry after a network timeout is harmless. DECRcannot go negative because we check first, and the check and the decrement are in the same atomic script. This is the entire double-booking guarantee.- The
SET ... EXgives a fast expiry path. TheZADDgives a scannable expiry index with a unix timestamp as the score — Redis keyspace notifications are fire-and-forget and will silently drop events under load. Never build your reaper on keyspace notifications alone.
Load the script once and call it by SHA:
$result = Redis::evalsha(
$this->holdScriptSha,
3,
"seats:{$eventId}",
"user_holds:{$eventId}",
"hold_expiry:{$eventId}",
$userId,
$holdId,
900, // 15 minutes
now()->addSeconds(900)->timestamp,
);
[$ok, $status, $returnedHoldId] = $result;At 5,000 concurrent requests against a single Redis node doing ~100k ops/sec, the p99 for this call is under 2ms.
Layer 3 — The hold is not a seat
A successful EVALSHA gives the user a 15-minute hold. This is the point where most designs get sloppy: they treat the Redis state as the sale and reconcile later. Don't. Write the durable row immediately.
CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE TABLE holds (
id UUID PRIMARY KEY,
event_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
status TEXT NOT NULL
CHECK (status IN ('pending','confirmed','expired','refunded')),
expires_at TIMESTAMPTZ NOT NULL,
confirmed_at TIMESTAMPTZ,
payment_intent TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- One live hold per user per event, enforced by the database.
CONSTRAINT one_active_hold_per_user
EXCLUDE USING gist (event_id WITH =, user_id WITH =)
WHERE (status IN ('pending','confirmed'))
);
CREATE INDEX idx_holds_reaper
ON holds (expires_at) WHERE status = 'pending';Breaking it down:
btree_gistis required so theEXCLUDEconstraint can use=on bigint columns. Without the extension, this DDL will not run.- The
EXCLUDEconstraint is a second line of defence. If your Redis hash is ever flushed or rebuilt wrong, Postgres still refuses to give one user two live holds. - The partial index on
expires_at WHERE status = 'pending'keeps the reaper query cheap — it only ever indexes ~500 rows, not the full history.
Now the invariant you defend for the rest of the sale:
seats_remaining (Redis) == total_seats − COUNT(holds WHERE status IN ('pending','confirmed'))If Redis dies, that equation is your recovery procedure. Pause admissions, run the count, SET the counter, resume.
Layer 4 — Payment reconciliation
This is where the actual production bugs live. The inventory counter is the easy part; the money is not.
Failure 1 — Duplicate webhooks
Every payment provider delivers webhooks at least once, not exactly once. Stripe will retry for up to three days. If your handler is not idempotent, a retried succeeded event confirms the same hold twice, sends two tickets, and in bad designs decrements inventory a second time.
Deduplicate on the provider's event ID, at the database level:
$inserted = DB::table('payment_events')->insertOrIgnore([
'provider_event_id' => $event->id, // UNIQUE constraint
'hold_id' => $holdId,
'received_at' => now(),
]);
if ($inserted === 0) {
return response()->noContent(); // Already processed. Not an error.
}
// First time seeing this event — safe to process.Breaking it down: insertOrIgnore returns the affected row count. Zero means the unique constraint rejected it, which means you already handled this event. Return 2xx so the provider stops retrying. Do not throw.
Failure 2 — The expired-hold race
The nasty one. The sequence:
10:00:00— user gets a hold, expires at10:15:00.10:14:58— user submits payment. The provider takes 4 seconds.10:15:00— your reaper expires the hold and returns the seat to Redis.10:15:02— a different user claims that seat.10:15:02— the webhook arrives. Payment succeeded. You have taken money for a seat you just sold to someone else.
There is no ordering trick that eliminates this. Physical time passes between authorization and confirmation. You have three real options:
| Approach | Customer impact | Complexity | When to use |
|---|---|---|---|
| Auto-refund | Money taken then returned; 3–5 day bank delay; support tickets | Low | Low-value tickets, tolerant audience |
| Overflow buffer | Invisible — customer gets their seat | Medium | Default choice for most sales |
| Authorize-only, capture on confirm | Clean, no funds move until confirmed | High — provider support varies | High-value tickets, regulated markets |
The overflow buffer is the pragmatic default. Sell 490 of your 500 seats through the counter and keep 10 in reserve specifically to absorb late confirmations:
// Load the sellable pool, not the physical inventory.
$sellable = (int) floor($totalSeats * 0.98); // 490 of 500
Redis::set("seats:{$eventId}", $sellable);The reaper then releases expired holds back into that sellable pool — but only if the row is still pending:
UPDATE holds
SET status = 'expired'
WHERE id IN (
SELECT id FROM holds
WHERE status = 'pending'
AND expires_at < now()
ORDER BY expires_at
LIMIT 100
FOR UPDATE SKIP LOCKED
)
RETURNING id, event_id, user_id;foreach ($expired as $row) {
Redis::evalsha(
$this->releaseScriptSha,
3,
"seats:{$row->event_id}",
"user_holds:{$row->event_id}",
"hold_expiry:{$row->event_id}",
$row->id,
$row->user_id,
);
}Breaking it down: the UPDATE ... WHERE status = 'pending' plus FOR UPDATE SKIP LOCKED is what makes the reaper safe to run on multiple workers. Only one worker's update will match a given row; everyone else gets zero rows and skips the INCR. Without this, two reapers release the same seat twice and you oversell.
Failure 3 — The reaper releasing a confirmed hold
If your reaper checks expires_at < now() without also checking status = 'pending', it will happily "expire" a hold that was confirmed 200ms earlier. The status check in the WHERE clause is not optional — it is the whole correctness argument.
Capacity: what each layer actually absorbs
| Layer | Requests handled | Latency budget | Infrastructure |
|---|---|---|---|
| CDN edge (sold-out flag) | ~1,500,000 | 15 ms | Cached, $0 origin cost |
| Waiting room token check | ~450,000 | 25 ms | Edge worker, no DB |
| App tier (hold attempts) | ~50,000 | 80 ms | 6–10 app containers |
| Redis Lua script | ~50,000 | 2 ms | 1 node, ~5% CPU |
| Postgres writes | ~500 | 10 ms | Single primary, no sharding |
| Payment webhooks | ~1,200 | 150 ms | Includes provider retries |
The point of the table: your database does 500 writes. Everything expensive was rejected upstream. That's the whole design in one number.
Production checklist
- Pre-load the Lua script with
SCRIPT LOADat deploy time and callEVALSHA. A coldEVALunder peak load re-sends the script body on every call. - Set
maxmemory-policy noevictionon the inventory Redis instance. A default LRU policy can evict your seat counter mid-sale. This has happened to real companies. - Run the reaper every 10 seconds, batched at 100 rows, with the
status = 'pending'guard in theWHEREclause. - Return 2xx from webhooks on duplicate events. A 4xx tells the provider to retry forever.
- Rate-limit by account, not IP. Corporate NAT and mobile carriers put thousands of legitimate users behind one address.
- Load test the waiting room, not the checkout. Your bottleneck is admission, and it's the layer nobody tests.
- Emit a metric for the Redis↔Postgres invariant every 30 seconds during the sale. Drift is your earliest signal that something is wrong.
- Make the countdown timer server-authoritative. Client clocks lie, and users will screenshot a timer that said 0:42 when you expired their hold.
- Log the
hold_idon every request from claim through confirmation. Without it, post-sale support is archaeology. - Rehearse the Redis rebuild before sale day. Knowing the query is not the same as having run it under pressure.
When this design fits, and when it doesn't
Use it when inventory is small and fixed, contention is extreme and time-boxed, and every unit is fungible — general admission tickets, limited product drops, appointment slots.
Don't use it when users pick specific seats. Seat maps change the problem entirely: you need per-seat state, real-time availability broadcast to thousands of open seat maps, and a reservation model closer to optimistic concurrency on individual rows. The counter approach doesn't survive contact with "I want row F, seat 12."
Also don't use it when contention is mild. If you have 5,000 seats and 8,000 buyers over an hour, SELECT FOR UPDATE SKIP LOCKED is correct, simpler, and one system less to operate. Don't build a waiting room for traffic that doesn't need one.
Conclusion
I've watched more of these sales fail on the payment path than the inventory path. The Redis counter is honestly the easy part — it's a decrement, it's atomic, it's done in an afternoon. What takes real care is accepting that a hold is a promise with a deadline, that money and inventory move on different clocks, and that some customer somewhere will pay 200 milliseconds after their timer runs out.
Build it in the order the traffic arrives: admission control first, because it's the only layer that changes your scale problem into a normal problem. Then the atomic counter. Then the hold lifecycle with Postgres as the ledger. Then spend the majority of your remaining time on webhook idempotency and the reaper's WHERE clause, because that is where double bookings are actually born.
Start with the invariant — seats_remaining == total − active_holds — write a monitor for it before you write the feature, and let every design decision after that be judged on whether it keeps that equation true.
FAQ
Why not just use a database transaction with SELECT FOR UPDATE?
It's correct but it doesn't scale. Two million concurrent requests serializing on 500 rows means every request queues behind a row lock, connection pools exhaust in under a second, and lock wait timeouts cascade into 5xx errors.
Correctness is not the problem — throughput is.
Is Redis DECR actually safe for inventory?
Yes, for a single Redis node. Redis executes commands on a single thread, so DECR and Lua scripts are atomic with no interleaving.
The risk isn't the counter — it's durability. Redis is the fast gate; Postgres is the ledger of record.
What happens if payment succeeds after the hold expires?
This is the hardest race in the system. You either auto-refund and notify, or you keep a small overflow buffer of seats (about 2% of inventory) specifically to absorb late confirmations.
Most ticketing platforms do both.
How do I stop bots from taking all 500 seats?
Enforce one hold per authenticated user inside the same atomic script that decrements inventory, require account age or phone verification before the sale opens, and rate-limit at the edge by account rather than IP.
Bot mitigation happens before the queue, not after.
Do I need Kafka for this?
No. The write volume that reaches your durable systems is 500 rows, not 2 million.
A queue is useful for fan-out after confirmation — emails, tickets, analytics — but it is not part of the seat allocation critical path.
What if Redis crashes mid-sale?
Rebuild the counter from Postgres: total seats minus the count of pending and confirmed holds. Because Redis holds derived state and Postgres holds the truth, recovery is a single query.
Pause admissions during the rebuild.