- Book: System Design Pocket Guide: Interviews
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
In November 2022, Ticketmaster ran the Verified Fan presale for Taylor Swift's Eras Tour. By the company's own account, bot attacks and fans without codes drove the site to 3.5 billion total system requests, four times its previous peak, and about 15% of interactions across the site experienced issues. The same post says over 2 million tickets sold on Ticketmaster on November 15, the most for any artist in a single day. Then the general public on-sale was cancelled (ET Online, CBS News), "due to extraordinarily high demands on ticketing systems and insufficient remaining ticket inventory to meet that demand."
A record sales day and a cancelled on-sale from the same system, in the same week. That contrast is the interview question. The part that has to be correct is an atomic seat hold with a TTL in Redis, plus a release script that can never free somebody else's seats, and both get real code below. The rest is capacity math and queueing.
Say the numbers out loud first
Ticketmaster's post hands you good inputs. Over 3.5 million people pre-registered, around 1.5 million were sent codes covering 52 show dates, and the other 2 million went on a waiting list. Historically, the post says, around 40% of invited fans show up and buy, and most buy an average of 3 tickets.
So the planning number is 1.5M × 0.40 × 3 = 1.8 million tickets, close to the 2 million Ticketmaster reports selling that day. Buyers were roughly the expected size. The traffic was not.
Ticketmaster didn't publish the window the 3.5 billion requests arrived in, so state an assumption. Say ten hours: 3.5 × 10⁹ ÷ 36,000 s ≈ 97,000 requests per second on average, with the opening minutes far above that. Spread 1.8 million purchases over the same ten hours and you get about 50 per second. Your job is to keep the 97,000 away from the 50.
The third number is the admission rate. By Little's law, L = λW: a booking tier sized for 20,000 concurrent shoppers (L), with sessions of about 5 minutes (W), can admit λ = 4,000 people a minute. The 600,000 expected shoppers (40% of 1.5 million) drain in 150 minutes. If every code holder shows up, it's 375 minutes, over six hours of queue. These numbers are illustrative; what counts is saying them before you draw a box.
Step 1: a waiting room that admits at a fixed rate
Nothing stateful should see the arrival storm. Users land on a cheap, cacheable waiting page, get a position, and get admitted at a rate you control. Queue-it calls this rate-based queuing: "giving a consistent number of new visitors access within a set time interval." Cloudflare Waiting Room uses two thresholds, total active users and new users per minute.
Ticketmaster pulled the same lever under load: "we slowed down some sales and pushed back others to stabilize the systems," and "the trade off was longer wait times in queue for some fans." Its post also says no one who wasn't verified could enter the queue. Do that rejection at the edge, before a request touches Redis.
Here's a minimal waiting room on Redis with redis-py. The queue is a sorted set ordered by score, and admitted users live in a second sorted set scored by when their session expires:
import secrets
import time
import redis
r = redis.Redis(decode_responses=True)
def wr_keys(event_id):
tag = "{" + str(event_id) + "}"
return f"q:{tag}", f"adm:{tag}", f"seq:{tag}"
def now_ms():
return int(time.time() * 1000)
def is_admitted(event_id, user_id):
_, admitted, _ = wr_keys(event_id)
score = r.zscore(admitted, user_id)
return score is not None and score > now_ms()
def join(event_id, user_id, opens_at_ms):
queue, _, seq = wr_keys(event_id)
if is_admitted(event_id, user_id):
return -1 # already admitted
if now_ms() < opens_at_ms:
score = secrets.randbelow(10**9) / 10**9
else:
score = 1 + r.incr(seq)
r.zadd(queue, {user_id: score}, nx=True)
return r.zrank(queue, user_id)
zadd(..., nx=True) means a refresh never moves you back in line. Anyone who joins before the sale opens gets a random score below 1, and everyone after gets 1 plus a counter, so early arrivals are shuffled among themselves and late arrivals queue in arrival order behind them.
Admission moves the front of the queue into the admitted set. ZPOPMIN removes the lowest scores first:
ADMIT_LUA = """
local popped = redis.call('ZPOPMIN', KEYS[1], ARGV[1])
for i = 1, #popped, 2 do
redis.call('ZADD', KEYS[2], ARGV[2], popped[i])
end
return #popped / 2
"""
admit = r.register_script(ADMIT_LUA)
ADMIT_PER_SEC = 67 # about 4,000 a minute
SESSION_MS = 10 * 60 * 1000 # time to shop once admitted
def admit_tick(event_id):
queue, admitted, _ = wr_keys(event_id)
expires = now_ms() + SESSION_MS
return admit(keys=[queue, admitted],
args=[ADMIT_PER_SEC, expires])
A single scheduler calls admit_tick once a second, starting when the sale opens, and the booking API rejects any request where is_admitted is false.
Treat SESSION_MS as a ceiling. The 4,000-a-minute rate was sized on 5-minute sessions; if real sessions run longer, the admitted set grows past the 20,000 the booking tier was built for. A closed-loop variant avoids that by counting live sessions with ZCOUNT and admitting up to a cap, closer to Cloudflare's total-active-users model.
The pop and the admit share one Lua script because Redis runs scripts atomically; with two calls, a crash between them drops a batch of users who already left the queue. Per the redis-py docs, register_script reloads the script and retries on a NOSCRIPT error.
The {event_id} braces are a cluster hash tag: only the part inside them is hashed, so every key for one event lands in the same slot and multi-key scripts work on Redis Cluster. The scripting docs also require every key a script touches to arrive through KEYS.
Step 2: seat holds that expire on their own
Keep two stores with two jobs. Postgres holds the truth about which seats are sold. Redis holds short-lived claims from people mid-checkout. The seat map is the sold set from the database plus the held set from Redis.
For one seat, a hold is a single command. SET with NX only writes if the key doesn't exist, and PX sets the expiry in milliseconds:
HOLD_MS = 8 * 60 * 1000
def seat_key(event_id, seat):
return "hold:{" + str(event_id) + "}:" + seat
def hold_one(event_id, seat):
token = secrets.token_hex(16)
ok = r.set(seat_key(event_id, seat), token,
nx=True, px=HOLD_MS)
return token if ok else None
The value is a per-hold random token from Python's secrets module, and it matters at release time.
Most people buy more than one seat. Holding A12 and A13 one at a time lets you win A12, lose A13, and strand a hold. The fix is an all-or-nothing script, plus a release script that only deletes keys still carrying your token:
HOLD_LUA = """
for i = 1, #KEYS do
if redis.call('EXISTS', KEYS[i]) == 1 then
return 0
end
end
for i = 1, #KEYS do
redis.call('SET', KEYS[i], ARGV[1], 'PX', ARGV[2])
end
return 1
"""
RELEASE_LUA = """
local n = 0
for i = 1, #KEYS do
if redis.call('GET', KEYS[i]) == ARGV[1] then
n = n + redis.call('DEL', KEYS[i])
end
end
return n
"""
hold_script = r.register_script(HOLD_LUA)
release_script = r.register_script(RELEASE_LUA)
Both helpers pass every seat key through KEYS, so the scripts stay cluster-safe:
def hold_seats(event_id, seats):
token = secrets.token_hex(16)
keys = [seat_key(event_id, s) for s in seats]
if hold_script(keys=keys, args=[token, HOLD_MS]) == 1:
return token
return None
def release_seats(event_id, seats, token):
keys = [seat_key(event_id, s) for s in seats]
return release_script(keys=keys, args=[token])
Check-then-set is safe only because the whole script runs atomically; no other client can slip in between the two loops. The release script is the multi-key form of the compare-and-delete pattern in the Redis SET docs. Without the token check, a slow client whose hold already expired could delete the hold a second shopper just took on the same seat.
Those docs also steer locks that need stronger guarantees toward Redlock. You can skip it here because a hold is never the source of truth. If a failover loses a hold and two people hold one seat, the database decides.
Step 3: payment, where the hold meets the database
The hold has to outlive the payment, and the database has to be the judge. The checkout flow, in order:
- Re-check the hold and reset its TTL to the payment window (script below).
- Authorize the card without capturing it. Stripe calls this placing a hold, with
capture_method=manual; an online card authorization is typically valid for 7 days. - Run a conditional
UPDATEin a transaction. If the row count is short, roll back and cancel the authorization. - Commit and capture. Send the authorize and capture calls with an idempotency key so a retry after a timeout never charges twice.
- Overwrite each hold key with a plain
SET key sold. Per the Redis docs, a successfulSETdiscards the old TTL, so the marker never expires and the hold script'sEXISTScheck blocks sold seats too.
The extend script refuses to touch any key that isn't still yours:
EXTEND_LUA = """
for i = 1, #KEYS do
if redis.call('GET', KEYS[i]) ~= ARGV[1] then
return 0
end
end
for i = 1, #KEYS do
redis.call('PEXPIRE', KEYS[i], ARGV[2])
end
return 1
"""
extend_script = r.register_script(EXTEND_LUA)
PAY_MS = 5 * 60 * 1000
def extend_hold(event_id, seats, token):
keys = [seat_key(event_id, s) for s in seats]
ok = extend_script(keys=keys, args=[token, PAY_MS])
return ok == 1
And the database write that actually sells the seats:
UPDATE seats
SET status = 'sold', order_id = $1
WHERE event_id = $2
AND seat_id = ANY($3)
AND status = 'available';
Postgres returns UPDATE count. If the count is lower than the number of seats you asked for, someone else got one first: roll back, cancel the authorization, offer a different set. Two buyers can both pass the Redis check after a lost hold; only one can pass this WHERE clause.
Step 4: releasing seats nobody bought
Two paths put seats back. When a shopper abandons checkout or a payment fails, call release_seats right away. When they walk off, the TTL does the work, with no sweeper job.
Don't drive the seat map from Redis expired notifications. The keyspace notification docs say there is no guarantee the event fires when the TTL reaches zero, that the delay can be significant, and that Pub/Sub is fire and forget. Read the hold keys when you render the map instead. Redis checks expiry when a command accesses a key, so an expired hold reads as missing even before background cleanup deletes it.
When inventory reaches zero, stop admitting and tell the people still in line. Ticketmaster's cancellation notice gave that exact reason: insufficient remaining ticket inventory.
Fairness against throughput: the trade-off interviewers probe
Each knob below buys fairness with throughput or the reverse, and interviewers ask about every one of them.
Queue order comes first. Queue-it's argument for randomizing the pre-queue is that "sales with a FIFO queue effectively start when the queue starts, not when the sale starts," which turns the opening second into a race fast connections and bots win. The random score below 1 in join is that idea in one line.
Raising the admission rate moves the line faster, right up until the inventory service falls over and nobody buys anything. Ticketmaster chose stability and accepted longer waits.
How long should a hold last? Someone typing a card number on a phone needs a few minutes. Every extra minute also keeps an abandoned seat out of circulation while people in line wait for it.
Let everyone pick from the map and admitted shoppers all race for the same front rows, so many holds fail. A "best available" button that assigns seats server-side spreads the load across the venue, at the cost of choice.
Scaling up alone doesn't settle this. Ticketmaster's post says that, based on the volume of traffic to its site, Taylor would need to perform over 900 stadium shows. Most people in line were never getting tickets, so the design decides who waits, for how long, and in what order. Say which side you picked for each knob, and why.
If this was useful
This question rewards the habit every good system design answer has: numbers first, then the one component where correctness lives, then the trade-off you chose on purpose. System Design Pocket Guide: Interviews works through 15 real system designs step by step, which is the practice that makes follow-ups like "what happens when the hold expires mid-payment" stop being surprises.

Top comments (0)