Designing a Virtual Waiting Room: Fair Queuing, Position Tracking, and Capacity-Gated Access for High-Demand Systems
A deep dive into virtual waiting room system design: distributed queue management, FIFO with jitter resistance, real-time position tracking without global locks, capacity-gated admission, bot detection, and production failure modes including clock skew and refresh-on-position-loss.
When a high-demand event goes live, your backend faces a very specific problem: demand exceeds capacity by orders of magnitude within seconds, and every user believes they arrived first. Ticket platforms crash. Product drops oversell. Flash sales buckle under the load. The naive solution is to throw more servers at it. The correct solution is to stop every excess request at the door and manage admission deliberately.
A virtual waiting room does exactly that: it decouples user arrival from system admission, enforces fairness, and releases capacity at a rate the downstream system can absorb. This article covers how to design one that is resistant to bots, honest about position, and recoverable when components fail.
The Core Architecture
The system has three layers.
Admission layer: every incoming request is intercepted before it hits your application. The request is either admitted (it holds a valid session token proving it already waited) or redirected to the queue. This is typically deployed at the edge or as a reverse proxy.
Queue layer: tracks who is waiting and in what order. The state here is the hardest part. It needs to be durable, fast, and fair across horizontally distributed ingressors.
Admission control layer: decides when to release users from the queue into the application. It observes downstream capacity signals and issues admission tokens on a schedule.
Each layer has distinct failure modes. Conflating them leads to designs where a Redis failure takes down not just the queue but also your product pages.
Queue Management with Distributed State
The classic choice is a Redis sorted set. Score the members by arrival timestamp. Members at the front of the set have the lowest scores (earliest timestamps).
interface QueueEntry {
userId: string;
sessionId: string;
score: number; // Unix milliseconds at arrival
fingerprint: string; // for bot detection
}
async function enqueue(redis: Redis, entry: QueueEntry): Promise<number> {
const key = "waitingroom:queue";
// NX: only add if not already a member
await redis.zadd(key, { score: entry.score, member: entry.sessionId, NX: true });
// Return absolute rank (0-indexed from front)
const rank = await redis.zrank(key, entry.sessionId);
return rank ?? 0;
}
async function getPosition(redis: Redis, sessionId: string): Promise<number | null> {
const rank = await redis.zrank("waitingroom:queue", sessionId);
return rank; // null if not in queue
}
The NX flag is critical. Without it, a user refreshing the page can bump their score to the current timestamp and effectively re-queue at the back. More importantly, it prevents an attacker from resetting their score by hammering the endpoint.
FIFO with Jitter Resistance
Pure timestamp ordering has a well-known problem: when a product goes on sale at exactly 10:00:00 UTC, thousands of bots and users hit the endpoint at the same millisecond. Your sort key loses all discriminating power.
Two mitigations work together:
1. Accept a window, not a moment. Open the queue 60 seconds before the event. Arrivals during that window are randomly re-ordered using a hash of the session ID, not a timestamp. Arrivals after the window opens are ordered by timestamp. This kills the “refresh-race-to-millisecond” problem for legitimate users and reduces bot advantage.
function computeScore(sessionId: string, arrivedAt: number, eventStartsAt: number): number {
const preWindowMs = 60_000;
const isPreWindow = arrivedAt < eventStartsAt;
if (isPreWindow) {
// Deterministic shuffle within the pre-window
// Use a hash so the score is stable across refreshes for the same session
const hash = murmurhash3(sessionId + "salt") % preWindowMs;
return eventStartsAt - preWindowMs + hash;
}
return arrivedAt;
}
2. Tie-break with session ID hash. When two entries have the same millisecond score, use a secondary sort derived from the session ID. Redis sorted sets are stable when scores are equal (lexicographic order on member), so you can encode the tiebreaker directly into the score as fractional microseconds.
function computeScoreWithTiebreak(sessionId: string, baseScore: number): number {
// Encode a stable sub-millisecond offset using the session ID hash
// Range: 0.0 to 0.999
const tiebreak = (murmurhash3(sessionId) % 1000) / 1_000_000;
return baseScore + tiebreak;
}
This gives you a globally stable ordering that does not depend on which server received the request.
Real-Time Position Tracking Without Global Locks
Naive position tracking uses ZRANK on every poll. At 50,000 concurrent waiters polling every 5 seconds, that is 10,000 Redis reads per second on a single key. This is actually fine for Redis (it handles millions of operations per second), but ZRANK is O(log N), and you are also running admission logic against the same sorted set.
A more scalable pattern separates position reads from queue mutations.
Publish position snapshots. A background process runs every 2 seconds, reads the current front-of-queue position (the absolute count of admitted sessions), and publishes it to a Redis pub/sub channel or stores it in a simple Redis string.
async function publishQueueSnapshot(redis: Redis): Promise<void> {
const totalAdmitted = await redis.get("waitingroom:admitted_count");
const queueLength = await redis.zcard("waitingroom:queue");
await redis.set("waitingroom:snapshot", JSON.stringify({
admittedCount: Number(totalAdmitted ?? 0),
queueLength,
updatedAt: Date.now(),
}), { EX: 10 }); // expire in 10s so stale data is visible as stale
}
async function getWaitEstimate(redis: Redis, sessionId: string): Promise<{
position: number;
estimatedWaitMs: number;
}> {
const [rank, snapshotRaw] = await Promise.all([
redis.zrank("waitingroom:queue", sessionId),
redis.get("waitingroom:snapshot"),
]);
const snapshot = snapshotRaw ? JSON.parse(snapshotRaw) : null;
const position = (rank ?? 0) - (snapshot?.admittedCount ?? 0);
const admissionRatePerMs = snapshot ? computeAdmissionRate(snapshot) : 10 / 1000;
return {
position: Math.max(0, position),
estimatedWaitMs: position / admissionRatePerMs,
};
}
Your polling clients call a lightweight endpoint that reads the snapshot and their rank. The snapshot is a cached value, not a live computation. This means your position display lags by up to 2 seconds, which is acceptable and far better than a hot path that serializes all reads.
Handling Refreshes and Session Continuity
Users refresh. This is unavoidable. When they do, you cannot re-issue a new session ID or they lose their position.
The session ID must be set before the user enters the queue, stored in a durable cookie (HttpOnly, Secure, SameSite=Strict), and must survive a page refresh. On every admission request, you check the cookie first:
async function handleAdmissionRequest(
req: Request,
redis: Redis
): Promise<Response> {
const sessionId = getSessionFromCookie(req);
if (!sessionId) {
// First visit: issue a session and enqueue
const newSessionId = crypto.randomUUID();
const score = computeScore(newSessionId, Date.now(), EVENT_STARTS_AT);
await enqueue(redis, { userId: getUserId(req), sessionId: newSessionId, score, fingerprint: getFingerprint(req) });
return issueQueueResponse(newSessionId, 302);
}
// Returning visitor: look up their existing position
const rank = await getPosition(redis, sessionId);
if (rank === null) {
// Session expired or was admitted: check if they hold a valid admission token
const token = getAdmissionToken(req);
if (token && await isValidAdmissionToken(redis, token)) {
return admitToApplication(req);
}
// Re-queue at end (session lost or expired)
const score = Date.now();
await redis.zadd("waitingroom:queue", { score, member: newSessionId });
return issueQueueResponse(sessionId, 302);
}
return issuePositionResponse(rank);
}
The re-queue fallback for expired sessions is a deliberate tradeoff. If a user closes their laptop for 30 minutes and comes back, their original position is gone. That is fair: the position was held under a time-bounded reservation.
Capacity-Gated Admission Control
Releasing users from the queue requires knowing how many the downstream system can absorb. There are two approaches: rate-based and capacity-signal-based.
Rate-based admission is simple. You decide that the checkout flow can handle 200 concurrent active sessions, and you admit 10 users every 30 seconds.
interface AdmissionConfig {
maxConcurrentSessions: number;
admissionBatchSize: number;
admissionIntervalMs: number;
}
async function runAdmissionCycle(redis: Redis, config: AdmissionConfig): Promise<void> {
const activeSessions = await redis.get("waitingroom:active_sessions");
const active = Number(activeSessions ?? 0);
const available = config.maxConcurrentSessions - active;
if (available <= 0) return;
const toAdmit = Math.min(config.admissionBatchSize, available);
const candidates = await redis.zpopmin("waitingroom:queue", toAdmit);
for (const { member: sessionId } of candidates) {
const token = await issueAdmissionToken(redis, sessionId);
await redis.publish(`waitingroom:admitted:${sessionId}`, token);
await redis.incr("waitingroom:admitted_count");
}
await redis.incrby("waitingroom:active_sessions", toAdmit);
}
Capacity-signal-based admission reads actual downstream health before admitting. If your checkout service exposes a /health endpoint with current concurrency, you can use that to drive the admission rate dynamically. This is more accurate but harder to implement reliably when the downstream service is under stress.
For most events, rate-based admission with a conservative estimate works well. The risk is under-admission: you leave capacity on the table. The alternative risk (over-admission) causes the exact failures you built the waiting room to prevent.
Batch vs. Continuous Release
Batch release (admit N users every K seconds) is simpler to reason about and avoids thundering herd at the application layer. Continuous release (admit one user per slot freed) is fairer and more efficient but requires tight coordination with the session lifecycle.
Batching every 30 seconds with a reasonable batch size (5-20% of concurrent capacity) is the right default. Smaller intervals with smaller batches approach continuous release without the coordination overhead.
Bot Detection and Proof-of-Work Integration
A waiting room is only as fair as its ability to distinguish real users from automated clients. Bots can hold queue positions, draining capacity that should go to humans.
Fingerprinting at admission. Collect passive signals at the time of queuing: user agent, accept-language, TLS fingerprint (via JA3/JA4 if available at the edge), and IP reputation. Score them into a risk level.
interface BotSignals {
userAgent: string;
acceptLanguage: string;
ipReputation: "clean" | "datacenter" | "vpn" | "known_bot";
tlsFingerprint?: string;
headersOrderHash: string; // hash of header order, which browsers are consistent about
}
type RiskLevel = "low" | "medium" | "high";
function assessBotRisk(signals: BotSignals): RiskLevel {
if (signals.ipReputation === "known_bot") return "high";
if (signals.ipReputation === "datacenter") return "medium";
// Browsers send headers in a consistent order; scrapers often do not
if (!isKnownBrowserHeaderOrder(signals.headersOrderHash)) return "medium";
return "low";
}
Proof-of-work for medium-risk sessions. For sessions assessed as medium risk, require a client-side proof-of-work challenge before finalizing queue position. The challenge is a hashcash-style problem: find a nonce such that SHA-256(sessionId + nonce) starts with N zero bits. Legitimate browsers solve this in under a second. Headless scrapers can solve it too, but it adds CPU cost that scales with the number of concurrent bot sessions.
// Server: issue challenge
function issueChallenge(sessionId: string, difficulty: number): string {
const challenge = crypto.randomBytes(16).toString("hex");
return JSON.stringify({ sessionId, challenge, difficulty });
}
// Client (browser): solve challenge
async function solveChallenge(sessionId: string, challenge: string, difficulty: number): Promise<number> {
const target = "0".repeat(difficulty);
let nonce = 0;
while (true) {
const attempt = `${sessionId}:${challenge}:${nonce}`;
const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(attempt));
const hex = Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, "0")).join("");
if (hex.startsWith(target)) return nonce;
nonce++;
}
}
// Server: verify solution
async function verifyChallenge(
sessionId: string,
challenge: string,
nonce: number,
difficulty: number
): Promise<boolean> {
const attempt = `${sessionId}:${challenge}:${nonce}`;
const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(attempt));
const hex = Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, "0")).join("");
return hex.startsWith("0".repeat(difficulty));
}
High-risk sessions get a full CAPTCHA gate, or are held at the back of the queue behind all verified sessions.
Estimated Wait Time
The naive formula is position / admission_rate. The problem is that admission rate is not constant: it depends on how long users spend in the application, which varies widely during high-demand events (people abandon carts, checkout times spike, sessions expire).
A better model tracks recent throughput as a rolling average.
interface AdmissionMetrics {
admittedLastMinute: number;
admittedLastFiveMinutes: number;
sessionExpirationsLastMinute: number;
}
function estimateWaitSeconds(position: number, metrics: AdmissionMetrics): number {
// Prefer 1-minute rate when queue is moving fast; fall back to 5-minute average
const recentRate = metrics.admittedLastMinute;
const smoothedRate = metrics.admittedLastFiveMinutes / 5;
const effectiveRate = recentRate > 0 ? recentRate * 0.7 + smoothedRate * 0.3 : smoothedRate;
if (effectiveRate === 0) return Infinity;
// Adjust for session churn: expired sessions create capacity without explicit release
const adjustedRate = effectiveRate + metrics.sessionExpirationsLastMinute;
return Math.round(position / (adjustedRate / 60)); // per second
}
Display this estimate conservatively. Users react badly to being told “5 minutes” and then waiting 15. Under-promise by applying a 1.2x buffer at the UI layer. When the estimate is below 60 seconds, switch to showing “almost there” rather than a countdown that might be wrong.
Edge-Based vs. Centralized Architecture
The two dominant deployment patterns have different tradeoffs.
| Dimension | Redis (Centralized) | Cloudflare Durable Objects (Edge) |
|---|---|---|
| State consistency | Strong (single node or cluster) | Strong within a single DO instance |
| Throughput | Very high (Redis is fast) | High, but DO instance is single-threaded |
| Geographic distribution | Requires replica coordination | Native: DO is routed to nearest PoP |
| Operational complexity | Low (well-understood) | Higher (new programming model) |
| Cold start behavior | No cold start | DO instances hibernate; cold start ~50ms |
| Cost at scale | Predictable | Variable; egress and DO invocations add up |
| Failure isolation | Single point unless clustered | DO crash affects only that instance |
For most teams, Redis is the right starting point. It is simple, well-documented, and the queue logic maps naturally to sorted set operations. A Redis Cluster with read replicas handles well over 100,000 concurrent queue members without tuning.
Cloudflare Durable Objects make sense when your traffic is globally distributed and you want to avoid a centralized bottleneck. A single DO instance becomes the authoritative queue manager for an event, and all edge workers coordinate through it. The programming model is simpler than it sounds: the DO is just a single-threaded actor that serializes all mutations.
// Durable Object: authoritative queue state
export class WaitingRoomQueue implements DurableObject {
private state: DurableObjectState;
private queue: Map<string, number> = new Map(); // sessionId -> score
constructor(state: DurableObjectState) {
this.state = state;
}
async fetch(request: Request): Promise<Response> {
const { action, sessionId, score } = await request.json<{
action: "enqueue" | "position" | "admit";
sessionId: string;
score?: number;
}>();
if (action === "enqueue" && score !== undefined) {
if (!this.queue.has(sessionId)) {
this.queue.set(sessionId, score);
await this.state.storage.put(sessionId, score);
}
return Response.json({ position: this.getRank(sessionId) });
}
if (action === "position") {
return Response.json({ position: this.getRank(sessionId) });
}
if (action === "admit") {
const admitted = this.popFront(5);
return Response.json({ admitted });
}
return new Response("Unknown action", { status: 400 });
}
private getRank(sessionId: string): number {
const score = this.queue.get(sessionId);
if (score === undefined) return -1;
let rank = 0;
for (const [, s] of this.queue) {
if (s < score) rank++;
}
return rank;
}
private popFront(n: number): string[] {
const sorted = [...this.queue.entries()].sort((a, b) => a[1] - b[1]);
const admitted = sorted.slice(0, n).map(([id]) => id);
for (const id of admitted) this.queue.delete(id);
return admitted;
}
}
The limitation here is that getRank is O(N) inside a single-threaded JS environment. For queues over 10,000 members, you need a sorted data structure. The DO storage API does not give you sorted range queries, so you either maintain an in-memory sorted structure (lost on hibernation) or use external storage for ranking with the DO as a coordination layer only.
Production Failure Modes
Queue Service Goes Down
If Redis goes down during an event, you have two options: fail open (admit everyone) or fail closed (return a 503). Failing open causes the downstream stampede you built the room to prevent. Failing closed turns your waiting room into a downtime page.
The right answer is a third option: drain the queue. When Redis connectivity is lost, stop issuing new queue entries and return 503 to new arrivals. For sessions already in the queue, issue admission tokens immediately based on their last known position, prioritizing sessions closest to the front. This requires a local cache of recent positions on your edge nodes. It is imperfect, but it means your most-waited users get in rather than losing everything.
Clock Skew Across Nodes
If your ingress nodes have unsynchronized clocks, a node that runs 50ms fast will assign earlier timestamps (lower scores) to users who arrive via that node, giving them an unfair advantage. NTP helps but does not eliminate skew in practice.
The fix is to generate scores server-side using a single authoritative time source, not the ingress node’s clock. Use a Redis TIME command to get the current timestamp from Redis itself when computing the score. This adds one network round trip to the enqueue path but eliminates clock skew as a fairness concern.
async function getCanonicalTimestamp(redis: Redis): Promise<number> {
const [seconds, microseconds] = await redis.time();
return Number(seconds) * 1000 + Math.floor(Number(microseconds) / 1000);
}
Admission Token Replay and Double-Spend
When you issue an admission token, a user could share it with others. Tokens must be single-use and tied to the session that earned them.
interface AdmissionToken {
sessionId: string;
issuedAt: number;
expiresAt: number; // short TTL: 5-10 minutes
nonce: string; // random, stored in Redis for single-use validation
}
async function issueAdmissionToken(redis: Redis, sessionId: string): Promise<string> {
const nonce = crypto.randomUUID();
const token: AdmissionToken = {
sessionId,
issuedAt: Date.now(),
expiresAt: Date.now() + 10 * 60 * 1000,
nonce,
};
// Store nonce for single-use validation
await redis.set(`waitingroom:token:${nonce}`, sessionId, { PX: 10 * 60 * 1000 });
return signToken(token, process.env.TOKEN_SECRET!);
}
async function consumeAdmissionToken(redis: Redis, tokenStr: string): Promise<string | null> {
const token = verifyToken(tokenStr) as AdmissionToken | null;
if (!token) return null;
if (token.expiresAt < Date.now()) return null;
// Atomic check-and-delete: returns the session ID if the nonce existed
const sessionId = await redis.getdel(`waitingroom:token:${token.nonce}`);
return sessionId;
}
The GETDEL operation atomically retrieves and deletes the nonce key. A second attempt with the same token finds the key gone and returns null.
Tradeoffs Summary
| Concern | Conservative choice | Aggressive choice |
|---|---|---|
| Queue ordering | Pre-window shuffle + timestamp | Pure timestamp (simpler, exploitable) |
| Position update frequency | Snapshot every 2s (cached reads) | Live ZRANK per poll (accurate but costly) |
| Admission mode | Rate-based batching | Capacity-signal continuous (more efficient) |
| Bot mitigation | Fingerprint + proof-of-work | CAPTCHA only (user friction) |
| Architecture | Redis (simple, proven) | Durable Objects (globally distributed) |
| Queue failure mode | Drain with local cache | Fail closed (simpler, worse UX) |
Closing
The virtual waiting room is fundamentally an admission control problem with a fairness constraint. The queue mechanics are straightforward. The hard parts are the edge cases: what happens when someone refreshes, when your queue store goes down, when your clock skews, when bots flood the pre-window.
Design for those cases first. Get the session cookie right so refreshes do not lose position. Use a canonical time source so clock skew cannot corrupt ordering. Make your admission tokens single-use and short-lived. Keep the queue layer independent from the application layer so a Redis restart does not take down your product pages.
When throughput matters most, the waiting room exists to protect your downstream system. Every design decision should come back to that constraint: rate the admission, not the applicants.
More in System Design
How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL
A deep dive into Aurora's storage-compute separation, the log-is-the-database design that pushes redo log processing to storage nodes, quorum-based replication across 6 copies in 3 AZs, protection group architecture, fast cloning, Serverless v2 scaling mechanics, and honest tradeoffs versus RDS, self-managed PostgreSQL, CockroachDB, and Cloud Spanner.
How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale
A deep dive into CockroachDB's internals: the 512MB range-based data model with automatic splitting, per-range Raft replication, MVCC timestamp ordering with hybrid logical clocks, DistSQL physical planning, serializable transactions with timestamp refreshes, closed timestamps for follower reads, online schema changes using multi-version schema descriptors, and production considerations for hotspots and write amplification.
How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation
Containers are not virtual machines and they are not magic. They are a thin composition of Linux kernel primitives: namespaces, cgroups, and a layered filesystem. This article traces exactly what happens between docker run and a running process, covering the OCI spec, containerd, runc, overlay filesystems, and container networking at the kernel level.
How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally
A deep dive into YugabyteDB internals covering the DocDB storage engine built on RocksDB LSM trees, per-tablet Raft consensus, YSQL and YCQL dual query layers, distributed MVCC with hybrid logical clocks, automatic tablet splitting and rebalancing, xCluster multi-region replication, and production considerations for hotspots, connection pooling, and schema design.