Design a virtual waiting room
Open in the interactive app: practice, quiz, mock interview →In one minute
- Queue at the edge, not at the origin. The waiting room is a thin layer in front of the protected site. It must absorb 100x the normal load while the origin sees a flat, chosen rate. If the queue lives on the machines you are protecting, the surge kills the queue first.
- State rides in a signed token. The browser carries a tamper-proof ticket (join time, bucket, signature), so any edge server can verify it without a lookup. The server keeps counters, not one row per person.
- Admission rate is derived from backend capacity. The room lets in
capacity - activepeople, capped by a per-second rate. Measured session length, not a guess, sets the number. The rate is a dial that operators and automation can turn. - Fairness is a policy, and bots attack it. First-in-first-out rewards the fastest script. Bucketing arrivals and ordering randomly inside a bucket, plus proof-of-work or challenge before joining, takes most of the speed advantage away.
- Abandonment and honest status decide whether it feels fair. Heartbeats release the places of people who left. Status shows position and an estimate that admits uncertainty, and says plainly when the event is likely to sell out.
Requirements
Functional
- A visitor who arrives while the protected site is full gets a ticket and a waiting page. When their turn comes they get a time-limited pass that the protected site accepts.
- The waiting page shows position (or a range) and an estimated wait, and updates without the visitor hammering refresh.
- A visitor who closes the tab and comes back within a grace period keeps their place. A visitor who leaves for good frees their place.
- Operators set the capacity, ordering policy (FIFO, bucketed random) and event schedule, and can pause admission or change the rate while the event is live.
- Passes expire so that a held pass cannot be hoarded, shared or replayed forever.
Non-functional
- Survive an arrival spike of 20,000 joins per second with the origin seeing no more than the configured admission rate.
- The room is more available than the origin: it keeps working when the origin is down, and it must not become a single point of failure for the whole site.
- Join and status calls answer in under 50 ms at the edge. Position may be a few seconds stale.
- Fair: no ordering advantage from faster networks or scripts beyond what the policy intends; no queue-jumping by rejoining or opening many tabs.
Out of scope: the purchase flow itself (seat holds, payments; see the flash sale and double-booking lessons), identity verification of buyers, and resale.
Back-of-envelope
Assume a stadium on-sale: 60,000 tickets, 3 million people who want them, most arriving in the first 10 minutes.
- Arrival peak. 3M visitors in 10 minutes is 5,000 joins/s on average. Real arrivals cluster at the start time, so plan for 4x: about 20,000 joins/s. A join is a token mint of a few microseconds; the cost is the connection rate, not the CPU.
- Origin capacity. Suppose the checkout path is load-tested at 8,000 concurrent shoppers. The mean shopper session is 8 minutes (480 s). By Little's law, the sustainable admission rate is 8,000 / 480 ≈ 17 admissions per second, about 1,000 per minute.
- How long the line is. If half of admitted shoppers buy a pair of tickets, 60,000 tickets need about 60,000 admissions. At 1,000 per minute that is one hour. The other 2.9 million people will never get in. The design should tell them early rather than make them wait.
- Status polling. 3M waiting visitors polling every 10 s is 300,000 requests/s. At 400 bytes each that is about 120 MB/s of egress. Polling is the biggest load in the system, bigger than joins, so its cost sets the design. Server-sent events or a longer poll interval for people far from the front cut it by 5x or more.
- State. One in-memory record per person is about 100 bytes: 3M x 100 B = 300 MB. That fits in a few nodes, but scanning it is costly, so the design keeps counters per arrival bucket instead.
API
POST /waitingroom/{event}/join
headers: Cookie: wr_ticket=<token> // optional; present on return visits
body: { "challenge": "<proof-of-work or CAPTCHA solution>" }
200 -> { "state": "waiting", "ticket": "<token>", "poll_after_ms": 8000 }
200 -> { "state": "admitted", "pass": "<token>", "expires_at": 1767225600 }
429 -> { "state": "retry", "retry_after_ms": 3000 } // join storm shed
GET /waitingroom/{event}/status
headers: Cookie: wr_ticket=<token>
200 -> { "state": "waiting", "position_range": [18200, 18800], "eta_min": 17,
"sold_out_likelihood": "high", "poll_after_ms": 10000 }
200 -> { "state": "admitted", "pass": "<token>", "expires_at": 1767225600 }
401 -> { "state": "invalid" } // bad signature
410 -> { "state": "expired" } // missed heartbeat window; must rejoin
Protected origin (every request):
Cookie: wr_pass=<token> // verified at the edge with the shared key
no valid pass -> 302 to /waitingroom/{event}
Operator (authenticated)
PUT /admin/events/{event} { "capacity": 8000, "max_admit_per_sec": 25,
"ordering": "bucketed_random", "bucket_seconds": 60,
"session_ttl_s": 600, "heartbeat_ttl_s": 45 }
POST /admin/events/{event}/pausePosition is returned as a range, not an exact integer. Exact numbers imply a precision the system does not have, and they invite people to compare notes and argue.
Data model
The system holds little durable data. Most of it is counters and signed blobs.
- Ticket token (client-held, signed):
{event, client_id, joined_at, bucket, nonce, key_id}plus an HMAC. The edge verifies it without any lookup.joined_atandbucketcome from the server clock, never from the client. - Pass token (client-held, signed):
{event, client_id, issued_at, expires_at, key_id}plus an HMAC. The origin or its edge verifies the signature and expiry on every request. - Bucket counters (store keyed by
event, bucket_id):{arrived, admitted, abandoned}per arrival bucket (for example one per minute). Position of a ticket is the sum of unadmitted counts in earlier buckets plus a within-bucket estimate. Counters are small and mergeable, so they shard by event and replicate cheaply. - Active set (per event): a count of live passes plus a sorted index of their expiries.
- Heartbeat window (per client): last-seen time in a TTL cache keyed by
client_id. - Event config (small, strongly consistent): capacity, rate, ordering policy, signing keys with ids and validity windows. Changes are versioned and pushed to every edge.
The partition key is the event id, then the bucket id. One on-sale is one hot partition, so counters are further split per edge location and summed periodically.
High-level design
flowchart LR U["Visitor browser"] --> E["Edge worker"] E -->|"valid pass"| O["Protected origin"] E -->|"no pass: mint ticket"| Q["Waiting page"] Q -->|"poll or SSE"| E E <--> C["Bucket counters"] A["Admission controller"] --> C M["Origin metrics"] --> A A -->|"open slots, rate"| C K["Config and signing keys"] --> E
Join path. The visitor hits the protected URL. The edge worker checks for a valid pass cookie. If there is one, the request goes through. If not, the worker checks whether the site is full. If the active count is below capacity and nobody is queued, it mints a pass and lets the visitor in. Otherwise it verifies a join challenge, mints a ticket for the current arrival bucket, increments that bucket's counter, and serves a static waiting page. Everything here is served from the edge, so the origin never sees the surge.
Admission path. A controller runs every second. It reads origin health and the live pass count, computes the number of free slots (bounded by max_admit_per_sec), and advances the admission watermark: the oldest bucket that is not yet fully admitted. Tickets at or behind the watermark are admitted. A ticket in the bucket currently being drained is admitted by a deterministic pseudo-random draw against the remaining fraction.
Read path (status). The waiting page polls status with its ticket. The edge verifies the signature, compares the ticket's bucket and draw with the watermark, and answers waiting with a range, or admitted with a fresh pass. The ticket cookie being refreshed is the heartbeat. No database row is read for a typical poll, only the cached watermark and counters.
Deep dives
1. Where the queue lives: signed tokens and counters
Option A: a central queue (a list or sorted set in Redis, one entry per visitor). It gives exact positions and trivial FIFO. But 3M entries and 300,000 polls per second hit one data structure, every poll is a rank query, and failover loses people's places.
Option B: stateless tokens. The ticket itself says when the holder arrived. The server only needs to know how far admission has progressed. Cloudflare's design is the model: each visitor gets a cookie with the timestamp of first arrival, and visitors are bucketed by arrival minute with the oldest buckets admitted first.
Choice: B with counters. Per-bucket counters give approximate positions at constant cost per poll. The price is that position is an estimate, and that the signing key becomes critical: leak it and anyone can mint a ticket for the front. Keys carry ids and validity windows so they can rotate while old tickets stay valid for a few minutes. Cloudflare also rejected an eventually consistent store for the global counts because stale reads over-admit. Each data center reports to one coordinating object per event, and the slice each location may admit follows its recent share of traffic.
2. Fairness and bots
Pure FIFO rewards the fastest connection and the best script. A bot that joins within a few milliseconds of the opening beats a person who clicked a second later. Cloudflare's docs describe FIFO as rewarding the people who queue first, and Random as the more equitable choice.
Bucketed random is the compromise: order by arrival bucket (so someone who arrived 40 minutes ago is not overtaken by someone who arrived a minute ago), and shuffle inside a bucket with a random draw fixed at join time. For the opening minute you can widen the first bucket so that "everybody within the first 5 minutes" is one lottery. This removes the millisecond race.
Bots then switch to quantity: many tickets per person. Defences stack, and none is perfect:
- Make joining cost something: proof-of-work or an invisible challenge, scaled up under suspicion.
- Bind identity: one ticket per account, device signal or pre-registered code. Ticketmaster's Verified Fan presale used pre-registration, and the 2022 Eras Tour sale still saw 3.5 billion system requests, 4x its previous peak, from bots and fans without codes.
- Make rejoining no better than staying: joining twice returns the same ticket.
Choice: bucketed random with a challenge on join and per-identity de-duplication. State the residual: a determined, well-funded operator will still get through, and the aim is to raise their cost above the resale margin.
3. Admission rate tied to capacity, and abandonment
The room admits at the rate the origin can absorb. Three controllers, from simplest:
- Static.
capacity = 8000, admit whenactive < capacity. Simple, but the number is a load-test guess and the real limit moves with a database failover or a slow payment provider. - Rate-limited static. Add
max_admit_per_sec, a token-bucket style cap. This stops a thousand slots opening at once when a batch of passes expires together, which would itself be a mini-surge. - Feedback. Lower the rate when origin p99 latency or error rate crosses a threshold, and raise it slowly, the same shape as TCP's additive increase and multiplicative decrease. Google's SRE book makes the same point about adaptive throttling and serving degraded responses when overloaded.
Choice: rate-limited static as the floor, with a feedback multiplier that can only reduce the rate automatically. Increases are a human decision, or a slow ramp.
Abandonment is the thing that makes capacity maths fail. A third of the queue may be gone within minutes. If their places stay reserved, admitted passes go unused and the origin idles while the line stalls. The fix is the heartbeat: each status poll refreshes a last-seen time, and a ticket that misses its window (45 s here) is dropped. The same applies after admission: an unused pass should expire in a minute or two, and a shopper's session should have a hard ceiling so a slot always comes back.
Down the stack
Layer 1The contract
- A visitor with a valid pass is served by the origin. A visitor without one never reaches the origin, except through a documented bypass (health checks, payment callbacks).
- A ticket confers a place, never access. A pass confers access for a bounded time, and only the room can mint one.
- Admission is monotone: if ticket A is ahead of ticket B under the policy and both stay live, A is admitted no later than B (exactly for FIFO, in distribution for bucketed random).
- The number of live passes never exceeds
capacity, and passes minted in any second never exceedmax_admit_per_secplus a small stated overshoot from replication delay. - Joining twice returns the same ticket. Missing the heartbeat window loses the place, and the response says so (
410).
Layer 2The mechanism
A ticket is base64(payload) "." base64(HMAC-SHA256(key_id, payload)). HMAC with a shared secret is enough because the edge both mints and verifies, so there is no need for public-key signatures; a JWT with HS256 (RFC 7519) is the same idea in a standard envelope. The payload holds the arrival bucket and a random draw taken at join, so ordering needs no server memory. Verification recomputes the MAC and compares in constant time, so response timing does not leak the signature.
Each admission tick does this: expire stale heartbeats and passes, add the elapsed time times the rate to a credit counter (capped at one second of burst), compute slots = min(capacity - active, floor(credit)), then advance the watermark by slots through the buckets in order. A pass is minted lazily, when the holder next polls and finds its ticket behind the watermark. This keeps the admission loop O(buckets), not O(people).
The status channel can be plain polling with a poll_after_ms hint, or server-sent events, which hold one cheap connection per visitor and push admitted the moment it happens. Joins shed under overload return 429 with a retry delay.
Layer 3The machine
At 20,000 joins per second the limit is accepting connections and TLS handshakes, not hashing. A TLS 1.3 handshake costs a few hundred microseconds of server CPU, while an HMAC-SHA256 over 100 bytes takes roughly 1 microsecond. Session resumption and HTTP/2 multiplexing keep the 300,000 polls per second on warm connections instead of a new TCP handshake each.
The waiting page is a few KB of static HTML and JS served from the edge cache, so 3M visitors cost cache hits, not origin work. A status response is a few hundred bytes, which fits in one TCP segment (MSS around 1,460 bytes) and one TLS record. Each SSE connection holds a socket and roughly 10 to 30 KB of kernel and TLS state, so one tuned edge node (file descriptors, epoll) holds hundreds of thousands.
Why it's built this way
- The surge is 100x the origin's capacity, so the origin cannot be the thing that sorts it out. The queue goes to the edge, where capacity is cheap and already global.
- 3M people polling needs lookups that cost nothing, so the token carries the state and the server keeps counters. That makes verification stateless and lets any edge answer any poll.
- Stateless tokens are forgeable if the key leaks and cannot be revoked one by one, so tokens are short-lived, keys are rotated by id, and passes are bound to a short expiry.
- Fairness is chosen by the business, and the cheapest attack is speed, so ordering uses buckets and randomness and joining has a cost.
- The origin's real limit moves, so the rate is a controlled dial with automatic decreases and human increases.
Failure modes
- 10x join surge (200,000/s). Joins are shed with
429and a randomized retry delay at the outermost layer, before challenge verification. The static waiting page is cached, so the visitor sees a page, not an error. Without jittered retries the shed requests return in lockstep. See the AWS guidance on backoff with jitter. - Counter store partition or loss. Edges that cannot reach counters fall back to a frozen watermark and admit nobody new. The origin stays safe at the cost of a stalled line. Never fail open into the origin, because the room exists to prevent exactly that.
- Signing key leaked. Rotate: publish a new key id, stop minting under the old one, and accept old tickets for the heartbeat window only. Passes signed with the old key expire within minutes. Monitor the share of tickets that join in the opening second.
- Region outage. Clients move to another region, which honours existing tickets once it has the latest watermark. Positions may jump, and the status copy should say so.
- Origin degraded. The feedback controller cuts the rate and operators can pause; existing pass holders continue.
- Thundering herd at admission. If a thousand passes expire at once, a thousand tickets would be admitted at once. The per-second rate cap spreads them.
In the wild
- Cloudflare, 2021: Waiting Room for vaccine sign-ups. Built for Project Fair Shot to protect COVID-19 vaccination sites, configured as total active users plus new users per minute. Users are bucketed by arrival minute and the oldest buckets admitted first. Per-data-center Durable Objects report to one global object, and each location's slot share follows its traffic ratio from two minutes earlier, so propagation delay causes only small overshoots. Workers KV was rejected as eventually consistent. One county site saw surges of up to 23,000 users (Cloudflare engineering blog).
- Ticketmaster, 2022: Eras Tour Verified Fan presale. Over 3.5 million people pre-registered and about 1.5 million were sent codes. Bot attacks and fans without codes drove 3.5 billion system requests, 4x the previous peak. Ticketmaster slowed some sales and pushed others back, which meant longer queue waits. It estimated about 15% of interactions had issues, including passcode validation errors that dropped carted tickets. More than 2 million tickets still sold that day (Ticketmaster, archived).
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| FIFO ordering | Easy to explain; rewards early arrival | Rewards speed and bots; the opening second decides everything | Low bot risk, small events |
| Bucketed random | Kills the millisecond race; early arrivals still favoured | Harder to explain; positions are estimates | High-demand on-sales |
| Central queue (sorted set) | Exact positions, simple code | Single hot structure; polls are rank queries; failover loses places | Small rooms under about 50,000 people |
| Stateless tokens + counters | Constant cost per poll; any edge answers | Approximate position; key management is critical | Large, spiky events |
| SSE or WebSocket push | Instant admit; far fewer requests | Many open sockets; needs reconnect handling | Waiting pages that stay open for long |
| Feedback-controlled rate | Tracks real origin health | Can oscillate; harder to test | After static is proven, with human-approved increases |
Leadership lens
- Rollout. Ship in passthrough mode first, which records arrival rate and session length without queueing anyone, as Cloudflare's docs recommend for choosing thresholds. Then run a dark-launch for a real small event, then a large one.
- Build or buy. A waiting room is a commodity at the edge. Build only if ordering rules, identity checks or purchase-flow integration are unusual. Either way you own the capacity number and the fairness policy.
- Ownership. The team that owns checkout owns the capacity number and must re-measure it before each event. Stale numbers cause most bad nights.
- Cost drivers. Egress for status polling, challenge verification and load-test time. Compute is minor.
- Questions for design review. What happens when the room is down? Who can raise the admission rate during the event? What do we tell the 2.9 million people who will not get in? How do we know the line is fair, and who audits it afterwards?
- Build first. Static capacity, signed tickets, bucket counters, heartbeat expiry and an operator pause.
Check yourself
Your origin handles 6,000 concurrent shoppers and the mean session is 5 minutes. What admission rate keeps it at capacity, and why does a longer real session mean the room must slow down?
Show answer
By Little's law, 6,000 / 300 s = 20 admissions per second. If sessions stretch to 10 minutes, the same rate would fill twice the capacity at steady state, so the rate must fall to 10 per second, or the active count will cross the limit.
Why is pure FIFO a weak fairness policy for an on-sale, and what does bucketing change?
Show answer
FIFO is decided by arrival order at millisecond precision, which a script wins. Bucketing groups arrivals into windows such as one minute and shuffles inside each window, so the speed advantage within a window vanishes, while people who arrived earlier still go first.
The counter store becomes unreachable during an event. Should edges fail open or closed, and what does the visitor see?
Show answer
Closed: freeze the watermark and admit nobody new, because opening the gate lets the whole surge hit the origin. Visitors keep seeing the waiting page with a status message, and existing pass holders continue.
Why does the status poll dominate the load, and what two changes cut it?
Show answer
3M waiting visitors polling every 10 s is 300,000 requests/s versus a few thousand joins/s. Longer intervals far from the front, and server-sent events, cut it several times over.
Sources and further study
Primary
- RFC 2104: HMAC: the keyed-hash construction behind signed ticket and pass tokens.
- RFC 7519: JSON Web Token: the standard signed-claims envelope, including expiry (
exp) semantics. - RFC 6585, section 4:
429 Too Many Requestsfor shedding joins. - HTML Standard: server-sent events: the one-way push channel for status, with automatic reconnect.
Systems docs
- Cloudflare Waiting Room docs: thresholds on active users and new users per minute, estimated wait, and session handling.
- Cloudflare Waiting Room: queueing methods: FIFO, random, passthrough and reject, and how ordering is derived from a cookie timestamp.
- Cloudflare: Building Waiting Room on Workers and Durable Objects: bucketed admission, global counts and the consistency trade-off.
Books
- Site Reliability Engineering, Beyer et al. (Google), chapter 21 "Handling Overload": load shedding, criticality and adaptive throttling.
- Release It!, 2nd edition, Michael Nygard, chapter 5 "Stability Patterns": load shedding, circuit breakers and bulkheads that keep a surge from spreading.
Other curricula
- AWS Builders' Library: Using load shedding to avoid overload: frames overload as a capacity-aware admission decision made as early as possible.
- Azure Architecture Center: Queue-Based Load Leveling: the pattern of buffering bursts so the backend works at a steady pace.
Free resources
- Cloudflare: Waiting Room announcement: why waiting rooms exist and how they protect origins during vaccine sign-ups.
- Fairness in multi-tenant systems (AWS Builders' Library): how quotas and admission control keep one burst of demand from starving everyone else.
- Timeouts, retries and backoff with jitter (AWS Builders' Library): why shed clients must retry with randomised delays.
- Handling Overload (Google SRE book): per-client quotas and adaptive throttling.