Design ticketing under extreme contention

"Design ticket sales for a stadium event. 50,000 seats, 500,000 people arriving in the same minute. No seat may be sold twice."

Step 1: clarify (3 minutes)

Are seats assigned or general admission? This changes everything. General admission is a counter, which is a decrement problem. Assigned seating is 50,000 individually identified resources, which is a locking problem. Assume assigned seating, because it is the harder case and because the counter case falls out of it.

Does a user select a seat, or does the system assign one? Interactive selection means a hold period during checkout, and holds are where the design gets interesting. Assume interactive selection with a 10-minute hold.

Is overselling ever acceptable? For airlines, yes, deliberately. For a stadium with physical seats, no. Assume strict: no double-sell, ever. This is the constraint that rules out the fast, sloppy designs.

What is the fairness requirement? "First come first served" and "random lottery among those who arrived in the first minute" produce completely different systems, and the second is much kinder to the infrastructure. Assume FCFS with a queue, because it is what most events use and because it is what people expect.

What are the peak numbers? 500,000 concurrent users, 50,000 seats. Ninety percent of arrivals cannot succeed, and that reframing is the single most useful thing to say early: this is primarily a system for rejecting people gracefully, and secondarily one for selling tickets.

Step 2: capacity math (3 minutes)

Demand        500,000 users in ~60 seconds  = ~8,300 arrivals/sec
Supply        50,000 seats
Success rate  10% at best; 90% of users must get a clear "sold out"

Browse traffic (the part people forget)
  Each waiting user polls seat availability every 2-5 s.
  500,000 users x 1 poll/3 s = ~165,000 reads/sec
  This dwarfs the write traffic and it must NOT hit the database
  that is arbitrating seat ownership.

Write traffic
  Hold attempts: bounded by the queue admission rate, not by arrivals.
  If we admit 500/sec, we get 500 hold attempts/sec. That is the point
  of the queue.

Seat state
  50,000 seats x ~200 bytes = 10 MB. Fits in memory anywhere.
  The problem is not data volume. It is CONTENTION on a small set of
  hot rows.

The reframing that drives the design: this is not a scale problem, it is a contention problem. Ten megabytes of state and 8,300 requests per second are unremarkable numbers. What is hard is that 500,000 people want the same 50,000 rows in the same second, and the popular sections concentrate that further: the front-row rows will be attempted thousands of times each.

Step 3: the virtual waiting room

Admission control is the first and most important component, and candidates who go straight to seat locking have skipped it.

       500,000 arrivals
              |
       ┌──────▼──────┐
       │  CDN / edge  │   static assets, event info, "queue" page
       └──────┬──────┘
              |
       ┌──────▼──────────────┐
       │  WAITING ROOM        │  issues a signed queue token with a
       │  (Redis sorted set)  │  position; polls tell the user where
       └──────┬──────────────┘  they are and an estimated wait
              |  admits N/sec
       ┌──────▼──────────────┐
       │  PURCHASE SERVICE    │  only admitted users reach this
       └──────────────────────┘
# Enqueue: one atomic op, score is arrival time.
def enqueue(user_id: str, event_id: str) -> int:
    score = time.time()
    r.zadd(f"queue:{event_id}", {user_id: score}, nx=True)
    return r.zrank(f"queue:{event_id}", user_id)

# Admission: a control loop, rate limited to what the purchase path
# can actually serve, with feedback from seat availability.
def admit_batch(event_id: str, rate: int) -> list[str]:
    users = r.zrange(f"queue:{event_id}", 0, rate - 1)
    if users:
        pipe = r.pipeline()
        for u in users:
            # A short-lived signed token: admitted, for 15 minutes.
            pipe.setex(f"admitted:{event_id}:{u}", 900, "1")
        pipe.zrem(f"queue:{event_id}", *users)
        pipe.execute()
    return users

Three things the waiting room buys you, and they are worth enumerating:

  1. The purchase path sees a load you chose, not a load the world chose. Every downstream capacity decision becomes tractable.
  2. Fairness becomes explicit and defensible. Position is by arrival time, visible to the user, and not by who has the fastest connection or the most aggressive retry loop.
  3. Bots are throttled at the cheapest possible layer, before they touch anything stateful.

The admission rate is a control loop, not a constant. Admit at a rate derived from seats remaining and observed conversion:

admit_rate = (seats_remaining / expected_conversion) / hold_duration_seconds

At 50,000 seats, 40% hold-to-purchase conversion, 600 s holds:
  (50,000 / 0.4) / 600  ~= 208 users/sec

Admitting much faster wastes holds on people who will find nothing left; admitting much slower leaves the venue selling for hours. And when seats run out, stop admitting and tell the queue, which converts 400,000 people from angry to informed.

Step 4: the seat hold, and the three ways to get it wrong

This is the core. Four candidate mechanisms; three fail.

Attempt 1: read then write. Wrong.

SELECT status FROM seats WHERE id = 'A-12-4';   -- 'available'
-- another transaction sells it here
UPDATE seats SET status = 'held' WHERE id = 'A-12-4';

Classic check-then-act race. Under this contention it fires constantly.

Attempt 2: SELECT ... FOR UPDATE. Correct but it serialises.

BEGIN;
SELECT * FROM seats WHERE id = 'A-12-4' FOR UPDATE;   -- blocks others
UPDATE seats SET status='held', held_by=$1, hold_expires=now()+'10 min';
COMMIT;

Correct, and every attempt on a hot seat queues behind the current holder. With thousands of attempts on the front row, the lock queue becomes the bottleneck and connection pools fill with waiters.

Attempt 3: conditional update. Correct, and it does not block.

-- The WHERE clause IS the concurrency control. No lock held across
-- a round trip; the database arbitrates in one atomic statement.
UPDATE seats
SET status = 'held', held_by = $1, hold_expires = now() + interval '10 minutes'
WHERE id = $2
  AND (status = 'available'
       OR (status = 'held' AND hold_expires < now()))   -- reclaim expired
RETURNING id;

Zero rows returned means someone else got it, and that is a normal outcome rather than an error. One statement, no read-then-write window, no lock held across application logic. This is the answer for the durable store.

Attempt 4: Redis as the arbiter, Postgres as the record. The production shape.

-- Atomic multi-seat hold. All or nothing: a user selecting 4 adjacent
-- seats must get all 4 or none, and doing that with 4 separate
-- operations creates a distributed-deadlock problem between users
-- grabbing overlapping sets.
local now = tonumber(ARGV[1])
local ttl = tonumber(ARGV[2])
local holder = ARGV[3]

for i = 1, #KEYS do
  local owner = redis.call('GET', KEYS[i])
  if owner and owner ~= holder then return {err = 'TAKEN: ' .. KEYS[i]} end
end
for i = 1, #KEYS do
  redis.call('SET', KEYS[i], holder, 'PX', ttl)
end
return #KEYS

Redis is single-threaded, so the Lua script is atomic with no locking protocol at all. Roughly 100,000 holds per second on one node, against a few thousand from Postgres under contention.

And the crucial constraint: Redis is the fast arbiter, not the source of truth. A hold lives in Redis; a sale is committed to Postgres with a unique constraint that makes double-selling impossible even if Redis is wrong.

-- The final backstop. Even if every layer above fails, this cannot
-- allow a seat to be sold twice.
CREATE UNIQUE INDEX one_sale_per_seat
  ON tickets (event_id, seat_id) WHERE status IN ('sold', 'reserved');

That index is the answer to "how do you guarantee it". Every other layer is optimisation; the unique constraint is the guarantee.

Step 5: hold expiry, and why TTL alone is not enough

A hold must expire if the user abandons checkout. Two mechanisms, and you need both.

Redis TTL          Automatic, precise, and INVISIBLE to Postgres.
                   The Redis key vanishes; the Postgres row still says
                   'held' until something notices.

Lazy reclaim       The conditional UPDATE in step 4 already reclaims
                   expired holds as a side effect of the next attempt.
                   Costs nothing, and it is the primary mechanism.

Sweeper            A background job releasing expired holds so the
                   available-seat count and the seat map are correct
                   for people browsing, not just for people attempting.

Lazy reclaim is the primary mechanism and the sweeper exists for display correctness. Getting that ordering right matters: a design that depends on the sweeper running promptly has a correctness dependency on a cron job.

The subtle failure: a user holds a seat, the hold expires during payment processing, and the payment then succeeds. Now you have taken money for a seat someone else holds. The fix is to extend the hold at the moment payment is initiated, not to rely on the original window covering the payment provider's latency, and to make the final commit conditional:

-- The sale only commits if we still hold it. If not, refund
-- immediately and automatically, and tell the user before they
-- have to ask.
UPDATE seats SET status='sold', sold_to=$1
WHERE id = $2 AND held_by = $1 AND hold_expires > now()
RETURNING id;

Step 6: the read path, which is 95 percent of the traffic

165,000 seat-map reads per second must never touch the seat-arbitration store.

Seat map rendering
  -> Static SVG/JSON of the venue, cached at the CDN forever.
     The layout does not change.

Availability overlay
  -> A compact bitmap: 50,000 seats = 6.25 KB as a bitset.
     Published to the CDN every 2 seconds with a 2 s TTL.
     165,000 reads/sec of a 6 KB object is trivial for a CDN and
     zero load on the origin.

Section-level counts
  -> "Section 112: 47 left". Cached, 5 s TTL. Coarse enough to be
     stable, useful enough to guide the user.

The design choice: browsing is eventually consistent, purchasing is strongly consistent. A user may see a seat that was taken two seconds ago, attempt it, and be told it is gone. That is acceptable and expected. The alternative, a strongly consistent seat map at 165,000 reads per second, would cost more than the entire rest of the system and would not improve the outcome, because the seat can be taken in the moment between render and click regardless.

Saying this explicitly is worth doing, because it looks like a compromise and it is actually the correct decision.

Step 7: failure modes

Redis fails
  -> Holds are lost. Sales are NOT, because they are in Postgres with
     the unique index. Degrade to conditional UPDATE against Postgres
     directly: slower, correct. Explicitly design and test this path.

Payment provider times out
  -> Do not release the hold. Extend it, poll for the payment status,
     and reconcile. Releasing a hold whose payment later succeeds is
     the expensive mistake.

Double payment on user retry
  -> Idempotency key on the payment request, derived from
     (user, event, seat set). Same key -> same charge.

A user's queue token leaks or is shared
  -> Sign it, bind it to the session, single-use for admission.

Bots
  -> The queue is the first defence, since position is by arrival.
     Then per-account and per-payment-instrument purchase limits,
     enforced at commit rather than at hold, because holds are cheap
     to attempt and expensive to police.

Sold out mid-queue
  -> Stop admitting and notify the queue immediately. 400,000 people
     receiving a clear "sold out" is a vastly better outcome than
     400,000 people reaching a purchase page that fails.

Step 8: what changes at ten times the scale

At 5 million arrivals for a 50,000-seat event (a genuine stadium tour on-sale):

The queue itself becomes the scaling problem. A single Redis sorted set with 5 million members and constant rank queries is expensive, because ZRANK is O(log N) and 5 million people polling for position is millions of ops per second. The move is sharded queues with a global admission controller: N independent queues, each admitting proportionally, with position reported approximately rather than exactly. Approximate is fine and users cannot tell.

Lottery replaces FCFS. At this ratio, first-come-first-served is a lie dressed as fairness: the outcome is decided by network latency and by who has a bot. A registration window followed by a randomised draw is fairer, and it flattens the load from a 60-second spike into a scheduled, capacity-planned process. Ticketmaster's Verified Fan and similar programmes exist for exactly this reason, and it is a product answer to an infrastructure problem, which is often the strongest kind.

Regional sharding of seat inventory. Partition seats by section and pin each partition to a region's Redis, so contention is distributed rather than global. Works because seat sections are naturally disjoint, and it is one of the rare cases where the domain hands you a clean partition key.

Production evidence

Ticketmaster's Smart Queue and Verified Fan are the reference implementations of admission control plus pre-registration, and their existence is evidence that the product-level answer (lottery, registration) is what large operators reached for after the infrastructure-level answers were exhausted.

Shopify's published work on flash sales describes the same shape: a checkout queue in front of inventory, admission at a rate the checkout path can sustain, and inventory reservations with expiry. Their write-ups on Black Friday capacity are the closest public analogue.

PostgreSQL's conditional-update pattern and SELECT ... FOR UPDATE SKIP LOCKED are documented mechanisms; the conditional UPDATE ... WHERE status='available' RETURNING form is the standard non-blocking claim and appears throughout the queue-on-a-database literature.

Redis's single-threaded execution model is what makes the Lua script atomic without a locking protocol, which is documented behaviour and is the reason multi-seat holds are tractable at all.

Amazon's and Shopify's inventory-reservation patterns both separate the reservation (fast, expiring, in a cache) from the sale (durable, unique-constrained), which is the two-layer design here.

The debate

The case for Redis as arbiter: two orders of magnitude more throughput on hot keys than a relational database under contention, atomic multi-key operations via Lua, and TTLs that make hold expiry free. Under this contention profile, the database will not keep up on the popular sections.

The case for the database alone: one system, one source of truth, real transactions, and a unique constraint that makes correctness structural rather than protocol-dependent. Adding Redis adds a consistency boundary, and consistency boundaries are where the bugs that survive testing live.

The case for a queue-first, sell-later design: accept all requests into a durable log, process the log serially per seat section, and email the outcome. Ordering is perfect, contention disappears entirely, and users hate it, because "you will find out in an hour" is a bad experience for a purchase.

My position: Redis arbitrates holds, Postgres owns sales with a unique constraint, and the waiting room is the first thing I build. The unique index is the guarantee and everything above it is optimisation, which means a Redis failure degrades performance rather than correctness. That property is worth designing for explicitly, and I would test the Redis-down path in a game day rather than assuming it works.

The decision I would defend hardest is admission control before seat locking. It is tempting to treat the waiting room as a nicety and the locking as the real problem, and that is backwards: with 500,000 users and 50,000 seats, 90 percent of the system's job is rejecting people clearly and fairly. Admission control makes every downstream capacity number a choice rather than a consequence, and without it no amount of clever locking saves you.

The second is conditional update rather than SELECT ... FOR UPDATE. Both are correct; only one avoids serialising every attempt on a hot seat behind a lock held across a network round trip. Under this contention profile that difference is the whole system.

Where I would push back on my own design is the FCFS requirement. At a 10:1 ratio it is defensible; at 100:1 it is a lie, because the winner is decided by network latency and by who runs a bot, and a lottery with pre-registration is both fairer and dramatically cheaper to operate. That is a product conversation, and raising it is more valuable than optimising the queue further.

Follow-up Q&A

"How do you guarantee a seat is never sold twice?" A unique partial index on (event_id, seat_id) where status is sold or reserved. That is the guarantee, and it holds even if Redis is wrong, the application is buggy, or two services race. Everything above it, the Redis holds and the conditional updates, is optimisation to avoid hitting that constraint constantly. I would say it in that order, because a design whose correctness depends on a protocol being followed is weaker than one whose correctness is a database constraint.

"Why not SELECT ... FOR UPDATE?" It is correct and it serialises. Every attempt on the front row queues behind the current holder, with a lock held across an application round trip, so connection pools fill with waiters and throughput on exactly the hottest seats collapses. A conditional UPDATE ... WHERE status='available' RETURNING is one atomic statement with no lock held across a round trip: zero rows means someone else got it, which is a normal outcome rather than an error.

"Why Redis at all if Postgres is correct?" Throughput on hot keys. Under this contention, thousands of attempts per second land on the same few hundred seats, and Redis being single-threaded makes a Lua script atomic with no locking protocol, at roughly a hundred thousand operations per second. It also makes multi-seat holds tractable: four adjacent seats have to be all-or-nothing, and doing that with four separate database statements creates deadlocks between users grabbing overlapping sets.

"A user selects four adjacent seats. How do you hold them atomically?" One Lua script that checks all four and then sets all four, which is atomic because Redis executes it single-threaded. The reason this matters is that the naive version, four independent claims, produces a deadlock pattern: user A holds seats 1 and 2 and wants 3 and 4, user B holds 3 and 4 and wants 1 and 2, and both fail after taking resources. An all-or-nothing script removes the class entirely.

"What happens if the hold expires during payment?" That is the expensive failure, because you take money for a seat someone else now holds. Two things. Extend the hold when payment is initiated rather than assuming the original ten minutes covers the provider's latency. And make the final commit conditional on still holding it, so if the hold was lost the sale does not commit and the refund is automatic and immediate rather than something the user has to discover and chase.

"165,000 people are polling the seat map. How?" From the CDN, and browsing is deliberately eventually consistent. The venue layout is static and cached forever. The availability overlay is a bitset, fifty thousand seats in about six kilobytes, republished every two seconds with a matching TTL. So a user may attempt a seat that was taken two seconds ago and be told it is gone, and that is fine, because the seat can be taken between render and click regardless. A strongly consistent seat map at that read rate would cost more than the rest of the system and would not change the outcome.

"Is first-come-first-served actually fair here?" At ten to one, defensibly. At a hundred to one it is a lie, because the winner is determined by network latency and by who is running a bot, not by who arrived first in any meaningful sense. At that ratio a registration window with a randomised draw is fairer and it converts a sixty-second infrastructure spike into a scheduled process you can capacity-plan. That is a product answer to an infrastructure problem, and I would raise it, because it is worth more than further optimising the queue.

"What if Redis goes down mid-sale?" Holds are lost, sales are not, because sales are in Postgres behind the unique index. The degraded path is conditional updates directly against Postgres: slower, still correct, and it will not sustain peak, so it pairs with lowering the admission rate. The important part is that this path is designed and exercised rather than assumed, because it is the one that only ever runs during an incident.

"How do you stop bots?" The queue is the first and cheapest defence, since position is by arrival time and a bot that retries aggressively gains nothing. Then per-account and per-payment-instrument limits enforced at commit rather than at hold, because holds are cheap to attempt and expensive to police. Then the usual device and behavioural signals. And honestly: at high enough demand the real answer is pre-registration with identity verification, which is why large operators built exactly that.

Common misconceptions

"This is a scale problem." Ten megabytes of state and 8,300 requests per second are unremarkable. It is a contention problem, and the popular sections concentrate it further.

"The seat map must be strongly consistent." It must not, and making it so is expensive and does not improve outcomes, because the seat can be taken between render and click anyway.

"SELECT FOR UPDATE is the answer to concurrency." It is correct and it serialises hot rows. Conditional update is correct and does not.

"Redis TTL is enough for hold expiry." The Redis key vanishes and the durable row does not. Lazy reclaim in the conditional update is the primary mechanism; the sweeper exists for display correctness.

"The waiting room is a nicety." It is the component that makes every other capacity number a choice. With 90 percent of arrivals unable to succeed, rejecting people clearly is most of the system's job.

Interview delivery note

Reframe the problem in the first thirty seconds, because it is what the question is really testing: "The first thing I'd say is that this isn't a scale problem. Fifty thousand seats is ten megabytes and eight thousand requests a second is unremarkable. It's a contention problem: half a million people want the same fifty thousand rows in the same second, and the front sections concentrate that further. And ninety percent of arrivals can't succeed, so most of what this system does is reject people clearly and fairly."

Then build admission control first, deliberately: "So I'd build the waiting room before the seat locking. A Redis sorted set keyed by arrival time, admitting at a rate derived from seats remaining over expected conversion over hold duration, which for fifty thousand seats at forty percent conversion and ten-minute holds is about two hundred users a second. That makes every downstream capacity number something I chose rather than something the world chose."

Give the guarantee before the optimisation, because that ordering is itself a signal: "The guarantee is a unique partial index on event and seat where status is sold. That holds even if Redis is wrong or two services race. Everything above it is optimisation to avoid hitting it constantly, which means a Redis failure costs me performance and not correctness."

Then the locking distinction: "For claims I'd use a conditional update rather than SELECT FOR UPDATE. Both are correct; only one avoids serialising every attempt on the front row behind a lock held across a round trip. Zero rows returned means someone else got it, which is a normal outcome."

Close with the product observation, which is the strongest thing available here: "and at a hundred to one rather than ten to one, I'd push back on first-come-first-served entirely. At that ratio it's decided by network latency and bots, so a registration window with a randomised draw is fairer and it turns a sixty-second spike into something I can capacity-plan. That's a product answer to an infrastructure problem, and it's worth more than optimising the queue further."

Further reading

  • PostgreSQL documentation on row-level locking and SKIP LOCKED, for the difference between blocking and non-blocking claim strategies.
  • Redis documentation on Lua scripting and its single-threaded execution model, for why multi-key atomic holds are cheap.
  • Shopify Engineering's flash-sale and Black Friday capacity write-ups, for admission control in front of inventory.
  • Ticketmaster's published descriptions of Smart Queue and Verified Fan, for the product-level answer at extreme ratios.
  • Kleppmann, Designing Data-Intensive Applications, chapter 7, on write skew and why the constraint belongs in the database.