System Design Case Study: Flash Sales and Ticketing
What prevents overselling in a flash sale is doing the stock check and the decrement in a single atomic step; a waiting room served from a CDN absorbs the spike, holds with a TTL manage the payment window, and expired holds return stock in the same statement. Real output with 2,000 concurrent buyers for 100 tickets: 2,000 reservations from naive code, a conditional UPDATE and optimistic concurrency, plus payment timeouts and bot defense.
Version note: The code in this post was built and run with C# 14 and .NET 10; the stock examples ran against PostgreSQL 17 with Npgsql 10, with 2,000 concurrent buyers in each scenario.
Guess First
Bookstore's events platform is about to sell 50,000 tickets for a big stadium concert where one of its authors will also appear on stage. Sales open at 10:00, and social media has been talking about it for weeks. The team's reservation code is plain: read the number of remaining tickets, if it's above zero decrement it and write it back, and assign the ticket to the buyer. In testing everything works.
Two questions. With 100 tickets left, if 2,000 people press "Buy" in the same second, how many reservations are created? And if a buyer's ten-minute window on the payment page runs out and the bank's approval arrives in the eleventh minute, what should happen?
Requirements and Capacity Estimation
The scenario in this post is a generic design inspired by problems often discussed around big concert sales in Turkey (the page not loading, the ticket slipping away at the payment step, bots buying for resale); it doesn't describe any specific platform. Functional requirements: the user joins a queue when sales open, picks and holds a ticket for a set time when her turn comes, pays within that time and gets her ticket; if she doesn't pay, the ticket returns to stock; a per-person ticket limit. The non-functional requirements are very clear this time. Overselling must never happen: tickets sold can't exceed stock by even one. The sudden traffic at the moment of sale must not bring the system down. The process must be fair and resistant to bots. Taking payment without issuing a ticket, or issuing a ticket without taking payment, is unacceptable.
Assumptions: 1 million people at the moment of sale, 6 page refreshes per person per minute, 50,000 tickets, and a reservation and payment side able to handle 500 buyers per second.
const double fans = 1_000_000, tickets = 50_000, checkoutPerSec = 500, refreshesPerFanPerMin = 6;
Console.WriteLine($"without a waiting room: {fans * refreshesPerFanPerMin / 60:N0} requests/s in the first minute");
Console.WriteLine($"admission at {checkoutPerSec} buyers/s: tickets gone after ~{tickets / checkoutPerSec / 60:N1} min, " +
$"{fans / tickets:N0} fans per ticket, position 200,000 would wait ~{200_000 / checkoutPerSec / 60:N1} min");
without a waiting room: 100,000 requests/s in the first minute
admission at 500 buyers/s: tickets gone after ~1.7 min, 20 fans per ticket, position 200,000 would wait ~6.7 min
Two numbers shape the design. The moment the gate opens, 100 thousand requests per second arrive, but the system behind it can process 500 buyers per second: something has to absorb that 200-fold difference. And there are 20 people per ticket; tickets run out in 1.7 minutes, while the 200,000th person in line would wait 6.7 minutes. Telling that person before making her wait is part of the requirements too. Unlike the series' earlier case studies, this is a system that sits idle most of the year and must withstand a hundredfold load for a few minutes.
API and Data Model
The flow splits into four calls:
POST /api/events/{id}/queue: join the queue; returns a signed queue token and the position in line. Queue status is polled withGET /api/queue/status.POST /api/events/{id}/holds: a user whose turn has come holds a ticket with the token and an Idempotency-Key; returnsholdIdandexpiresAt.POST /api/holds/{holdId}/payment: starts the payment; the payment provider's result arrives via a callback (webhook).GET /api/holds/{holdId}: the hold's status (held, confirmed, expired).
At the center of the data model are two tables: events (remaining tickets per event) and holds (who, for which event, in what status, until when). In venues with numbered seats, a seats table replaces the remaining count and each seat is held on its own row; the patterns below apply to a seat row just the same.
High-Level Design

The user first enters the waiting room: a static page served from a CDN (Content Delivery Network) plus a small queue service. A user whose turn comes moves on to the reservation step with a signed token; stock drops in one atomic step and a ten-minute hold is created. If payment succeeds, the hold is confirmed and the ticket is issued. If the time runs out or the payment fails, the hold is dropped and the ticket returns to stock; a payment arriving after expiry is refunded. What all the red paths have in common is that none of them leaves a "maybe".
Deep Dive: The Virtual Waiting Room
Feeding 100 thousand requests per second straight into a system that handles 500 per second would be a textbook case of the goodput collapse from the series' "Reliability Patterns: Timeouts, Retries, Backoff with Jitter, Circuit Breakers and Load Shedding" post: the system half-processes every request, finishes none, and users multiply the load by hitting refresh. The waiting room is a queue that absorbs that difference; the user-visible version of the load leveling in the series' "Asynchronous Processing: Queues, Workers, Fan-Out and Backpressure" post.
The parts of the design are these. The waiting page is static and served from a CDN; a million people refreshing it never touch the origin server. A user joining the queue gets a signed token (with an HMAC, Hash-based Message Authentication Code; containing the user, the event and the position in line); the reservation endpoint rejects a request without a token, or with a token whose turn hasn't come, before it ever reaches the database. The admission rate isn't a fixed number but a value tied to the health of the system behind it: if the payment provider slows down, the gate narrows. The client's queue status polling interval has jitter, otherwise a million clients ask in the same second. And the moment tickets run out, everyone in line is told; making the 200,000th person from the capacity table wait 6.7 minutes for nothing is unfair to both the user and the system.
There's one more decision for fairness: people who arrive before sales open. Ordering the line by arrival time between someone who showed up at 09:59 and someone who showed up at 09:50 rewards whoever refreshes the most and the fastest bot. A common answer is to put everyone who arrives before opening into the same pool and assign their positions randomly, then append those who arrive after opening in arrival order.
Deep Dive: Preventing Overselling
I tried the first Guess First question in PostgreSQL, with 2,000 concurrent buyers each running on its own connection. The naive code:
// Read, check, write: three steps, nothing stops two buyers from reading the same number
public async Task<bool> NaiveReserveAsync(string userId)
{
await using NpgsqlConnection conn = await db.OpenConnectionAsync();
await using NpgsqlCommand read = new("SELECT remaining FROM events WHERE event_id = 1", conn);
int remaining = (int)(await read.ExecuteScalarAsync())!;
if (remaining <= 0) return false;
await using NpgsqlCommand write = new("UPDATE events SET remaining = @r WHERE event_id = 1", conn);
write.Parameters.AddWithValue("r", remaining - 1);
await write.ExecuteNonQueryAsync();
await InsertHoldAsync(conn, null, userId);
return true;
}
I repeated the same load with two fixes. The first moves the check and the decrement into a single conditional UPDATE and writes the hold in the same transaction; the second uses optimistic concurrency against a version column and retries on conflict:
// Check and decrement in one statement; the hold is written in the same transaction
public async Task<bool> AtomicReserveAsync(string userId, TimeSpan holdFor)
{
await using NpgsqlConnection conn = await db.OpenConnectionAsync();
await using NpgsqlTransaction tx = await conn.BeginTransactionAsync();
await using NpgsqlCommand dec = new(
"UPDATE events SET remaining = remaining - 1 WHERE event_id = 1 AND remaining > 0", conn, tx);
if (await dec.ExecuteNonQueryAsync() == 0) return false; // sold out; dispose rolls back
await InsertHoldAsync(conn, tx, userId, holdFor);
await tx.CommitAsync();
return true;
}
// Optimistic concurrency: succeed only if nobody changed the row since we read it
public async Task<(bool Reserved, int Conflicts)> OptimisticReserveAsync(string userId)
{
await using NpgsqlConnection conn = await db.OpenConnectionAsync();
for (int conflicts = 0; ; conflicts++)
{
await using NpgsqlCommand read = new("SELECT remaining, version FROM events WHERE event_id = 1", conn);
await using NpgsqlDataReader r = await read.ExecuteReaderAsync();
await r.ReadAsync();
(int remaining, int version) = (r.GetInt32(0), r.GetInt32(1));
await r.CloseAsync();
if (remaining <= 0) return (false, conflicts);
await using NpgsqlCommand write = new("""
UPDATE events SET remaining = @r, version = version + 1 WHERE event_id = 1 AND version = @v
""", conn);
write.Parameters.AddWithValue("r", remaining - 1);
write.Parameters.AddWithValue("v", version);
if (await write.ExecuteNonQueryAsync() == 1)
{
await InsertHoldAsync(conn, null, userId);
return (true, conflicts);
}
}
}
naive : 2000 reservations for 100 tickets, remaining = 69
atomic : 100 reservations for 100 tickets, remaining = 0
optimistic: 100 reservations, 942 version conflicts retried
The naive code created 2,000 reservations for 100 tickets, and the counter still shows 69 tickets left; on the previous run it was 57. Because each buyer reads a stale value and writes "remaining − 1" as an absolute value, the writes overwrite each other (lost update). PostgreSQL's default isolation level, READ COMMITTED, doesn't prevent this; under SERIALIZABLE the database would reject these conflicts with serialization errors and every buyer would need a retry. This is the at-scale version of the race condition in the post "OWASP A06: Insecure Design — Business Logic Flaws, Race Conditions and Rate Limiting" from the Web Security series, which covers the OWASP (Open Worldwide Application Security Project) Top 10.
The conditional UPDATE produced exactly 100 reservations. It works because the check (remaining > 0) and the decrement happen in a single statement under the same row lock: the second buyer waits for the first one's commit and sees the current value. Optimistic concurrency also gave the right result, but with 942 conflicts and retries for 100 successes. As we covered in the Solution Patterns series post "Optimistic Concurrency End to End: From rowversion to ETag, If-Match, 412 and a Conflict UI", optimistic concurrency shines where conflicts are rare; a flash sale where everyone hits the same row is the exact opposite.
The conditional UPDATE hits a limit somewhere too: since everyone writes to the same row, that row's lock becomes the system's ceiling. For 500 buyers per second, a single row is usually enough. If you need more, there are two ways. Splitting stock into N sub-counters (each holding 50,000/N tickets, the buyer decrements a random one and skips empty ones) divides lock contention by N. Or moving the counter to Redis: with the atomic Lua script approach from the series' "Designing Rate Limiting: Token Bucket, Sliding Window and a Distributed Rate Limiter" post, DECR only when above zero and write the hold record to the database asynchronously. The second is much faster, but now there are two sources of truth, and the counter in Redis and the hold count in the database need reconciliation.
Deep Dive: Holds, TTLs and Payment Timeouts
A hold is how you keep a ticket from going to someone else during payment; but a hold kept forever means non-paying users lock up tickets. So every hold has an expires_at, and expired holds give their tickets back to stock. There are two critical points: marking an expired hold and incrementing stock must happen in the same atomic step, and the payment confirmation must only confirm a hold that is still valid:
// Expired holds give their tickets back in the same statement that expires them
public async Task<int> ReleaseExpiredAsync()
{
await using NpgsqlCommand cmd = db.CreateCommand("""
WITH expired AS (
UPDATE holds SET status = 'expired'
WHERE status = 'held' AND expires_at < now()
RETURNING event_id)
UPDATE events e SET remaining = e.remaining + x.n
FROM (SELECT event_id, count(*) AS n FROM expired GROUP BY event_id) x
WHERE e.event_id = x.event_id
RETURNING x.n
""");
return Convert.ToInt32(await cmd.ExecuteScalarAsync() ?? 0);
}
// Payment callback: confirm only a hold that is still valid
public async Task<bool> ConfirmAsync(long holdId)
{
await using NpgsqlCommand cmd = db.CreateCommand(
"UPDATE holds SET status = 'confirmed' WHERE hold_id = @id AND status = 'held' AND expires_at > now()");
cmd.Parameters.AddWithValue("id", holdId);
return await cmd.ExecuteNonQueryAsync() == 1;
}
I created 100 holds; 70 paid, and 30 expired (by moving their time back). Then I tried a late payment for an expired hold and a double click from a user who already has a ticket:
holds : 70 confirmed, released 30 expired holds, remaining = 30
late pay : confirm expired hold 71 -> rejected, refund the payment
double hold: 23505 duplicate key value violates unique constraint "ux_holds_active"
after double click: remaining = 30
The 30 expired tickets went back to stock in a single statement. The answer to the second Guess First question is on the third line: confirmation rejects the expired hold, and the only right move is refunding the money. Issuing the ticket anyway would be a back door to overselling, since that ticket may already have been sold to someone else. The double click was caught by the filtered unique index on (event_id, user_id) (WHERE status IN ('held', 'confirmed')); when the insert failed the transaction rolled back, the stock that had just been decremented came back, and the counter stayed at 30. The same index also enforces the one-ticket-per-person limit at the database level.
The hardest moment on the payment side is a timeout. When the call to the payment provider times out, you don't know whether the money was taken. Dropping the hold right away then means a user who was charged loses her ticket; waiting forever means locking up the ticket. The right sequence: start the payment with an Idempotency-Key (when retried with the same key, the provider won't charge twice), keep the hold in a "payment uncertain" state on timeout, ask the provider for the status using the key, and only then confirm or release based on the answer. The hold duration is chosen to leave room for that inquiry. Since callbacks can arrive more than once, the confirmation step itself must be idempotent; the conditional UPDATE above returns 0 rows on a second callback and changes nothing.
Deep Dive: Bot Traffic
In ticketing, a bot is software that calls the same endpoints as a real user at superhuman speed and collects tickets for resale. No single defense is enough; the layers complement each other. At the account level: only verified accounts (by phone or ID) created before the sale can take part, and the per-person ticket limit is enforced in the database with the unique index above. At the queue level: the token is bound to the user and the session and can't be moved to another client; entering the waiting room can require a CAPTCHA (Completely Automated Public Turing test to tell Computers and Humans Apart) or similar human verification. At the request level: rate limits per account and per IP (Internet Protocol), and a WAF (Web Application Firewall) with bot detection rules at the edge. At the business rule level: printing the name on the ticket and restricting transfers, one of the most effective deterrents because it breaks the economics of resale. None of these is enough alone; the goal is to make a bot cost more than the advantage it would gain over a real user.
Trade-offs
| Decision | Our choice | Alternative and its cost |
|---|---|---|
| Sudden traffic | Static waiting room from CDN + signed token | Direct sale: everything slows down at once and nobody can buy |
| Stock decrement | Conditional UPDATE, single transaction | Optimistic concurrency: correct but hundreds of retries under heavy contention; Redis counter: faster but two sources and reconciliation |
| Payment window | 10-minute hold + batch expiry | No hold: the ticket slips away at payment; long hold: non-payers lock up stock |
| Payment timeout | Uncertain state, ask the provider, then decide | Release immediately: a charged buyer loses the ticket |
| Early arrivals | Pre-opening pool, random order | Arrival order: whoever refreshes most and the fastest bot win |
Common Pitfalls
Read, check, write
A stock check done with separate statements breaks under concurrent load; above, 2,000 reservations for 100 tickets. Check and decrement must be a single atomic step.
Separating expiry from returning stock
Marking a hold expired and incrementing stock in a separate step loses the ticket, or returns it twice, when a job crashes between the two. Both belong in the same statement or the same transaction.
Issuing a ticket for an expired hold
Issuing the ticket "anyway" for a late payment can mean selling that ticket a second time. Confirmation is conditional; a late payment is refunded.
Running the waiting room on the origin server
If the waiting page also comes from the application server, it goes down in the first minute too. Static content lives on the CDN; the queue service is small and separate.
Testing sale day for the first time on sale day
This system sees real load for a few minutes a year. Rehearse several times the expected traffic with load testing in advance, and set the admission rate from that measurement.
A Small Experiment
★☆☆ Redo the capacity estimate with 3 million people and a capacity of 2,000 buyers per second. How many minutes until tickets run out, and when should the 1,000,000th person in line hear "sold out"?
★★☆ Split stock into 10 sub-counters: use event_stock(event_id, bucket, remaining) instead of events, have the buyer decrement a random bucket and try the others if it's empty. In the 2,000-buyer test, is the reservation count still exactly 100?
★★★ Add a "payment_pending" state for payment timeouts: on timeout the hold moves to this state, ReleaseExpiredAsync doesn't touch these holds, and a separate job asks the provider and confirms or releases them. In which case does the user wait the longest, and for how long?
With this post the System Design series comes to an end. We started by clarifying requirements and putting numbers on paper; we examined the building blocks one by one: load balancing, caching, replication and sharding, consistent hashing, queues, rate limiting, reliability and ID generation; and in the last four posts we brought them together in a URL (Uniform Resource Locator) shortener, a news feed, a chat system and ticketing. In every case study we came back to the same question: which requirement forces which trade-off? The theory underneath these designs, that is, replication and consistency models, a correct reading of the CAP (Consistency, Availability, Partition tolerance) theorem and of PACELC (Partition, Availability, Consistency, Else, Latency, Consistency), which adds that even without a partition there's a choice between latency and consistency, clocks and consensus, is the subject of the upcoming Distributed Systems Fundamentals series.