An Eight Mile service · private
QueueOps
A virtual waiting room for ticket on-sales. Your platform opens a room for each sale and sends its signed-in buyers to join. QueueOps puts them in a fair order, lets them in at the pace the sale can take, and gives each one a signed admission your platform checks itself, so a sale under way does not depend on QueueOps staying up.
Runtime
TypeScript · Redis
Engine
Lua · atomic
Admission
ES256 · local check
Tests
243 · real Redis
one sale
wait → admit → check
Why a waiting room
An on-sale is a crowd arriving at once.
When a big match goes on sale, thousands of buyers arrive in the same minute. Let them all at the seat map and it slows for everyone, and whoever refreshes fastest gets the seats. A waiting room holds the crowd outside, puts it in order and lets people in as fast as the sale can serve them.
A fair order
Arriving first before the sale is no edge.
Everyone who joins before the sale opens holds a place in a random order, drawn when the sale opens. Anyone who joins after that goes to the back, in the order they arrived. A waiting buyer’s position only ever goes down.
A pace the sale can take
Only so many shop at once.
You set how many buyers can be in the sale together. Every second, QueueOps lets in as many from the front as there are free slots, and never more, however many copies of it are running.
One place per person
Refreshing does not buy a second place.
A person has one place and one turn, and joining again hands back the place they hold. A room has a capacity and each address a limit, both checked in the step that gives the place. Joining goes only through your platform, after its own checks, so a bot cannot join QueueOps directly.
A sale that keeps selling
Your checkout never calls QueueOps.
An admitted buyer carries a signed token, and your platform checks it with keys it pinned beforehand. If QueueOps went down mid-sale, buyers already let in would keep buying, and new joins would be refused rather than guessed at.
A buyer’s path
Six states, and a position that only goes down.
The browser asks where it stands, and the answer says how long to wait before asking again: often near the front, rarely far back, each interval spread a little so a crowd that joined together does not ask together. A browser that asks too soon is turned away before the database is touched.
State
What the buyer is told
Asks again
pre_sale
Has a place
Joined before the sale. No position yet, because the order is drawn when the sale opens.
~30 s
waiting
In the queue
A position, how many are still waiting, and an estimated wait once the room’s pace has been measured.
3–15 s
admitted
In the sale
Holds a signed admission until its time is up, and the platform lets them reserve with it. Finishing early gives the slot to the next buyer.
~30 s
expired
Turn ended
The admission ran out. The place stays spent.
~60 s
left
Left
Left the queue, or finished and gave the slot back.
~60 s
closed
Room closed
The room was switched off or is gone. The buyer joins again through the platform.
~60 s
What you set for each sale
Nine settings, all of them sent on every save.
A room is one call with every field in it, so a retry or a re-sync is the same call again and a typo is refused rather than dropped. Changes to a live room apply at once: more capacity lets more people join, and a new pace applies from the next second.
| Setting | What it decides | Allowed |
|---|---|---|
capacity | The most people who can ever hold a place. It can be raised on a live room. | 1 to 1,000,000 |
queueOpensAt | When joining opens. | before the sale |
saleOpensAt | When the order is drawn and admissions start. | before expiry |
maxActive | How many shop at once. A change applies on the next tick. | 1 to 100,000 |
maxPerClientKey | How many places one address can take. | 1 to 1,000,000, or none |
admissionTtlSeconds | How long an admitted buyer has to finish. | 30 s to 1 hour |
expiresAt | When QueueOps forgets the room and everything in it. | in the future |
status | Closed means no joins and no admissions, kept until it expires. | active or closed |
version | A higher one replaces the room, an equal one is applied again, a lower one is ignored. | a positive integer |
How a platform plugs in
Six steps, and only one of them is ours.
QueueOps knows nothing about events, seats or customers: a room is your id for the sale, a visitor your id for the person, and an address an opaque hash. Any platform with an account can use it, and TicketOps is the first.
01
An account
We make your platform an account. It comes with an API key, shown once, and the public keys your platform pins to check admissions.
02
A room for each sale
One call creates it: the capacity, when the queue and the sale open, how many shop at once. Sending it again is harmless, and a newer version replaces it.
03
Joins, after your checks
When a signed-in buyer asks to join, your platform calls QueueOps and hands the browser a visitor token. QueueOps gets your id for the person and a hash of their address, never the address or who they are.
04
The browser asks
It polls its status through your own domain, as often as it is told to, and receives its admission when its turn comes.
05
You check, locally
At checkout your platform verifies the token’s signature, room, person and expiry against the keys it pinned. It never asks QueueOps whether someone is admitted.
06
A key check
QueueOps says which key signs your admissions now, so your platform can confirm it has pinned that key before a key change could turn anyone away.
Where it stops
What it has not done yet, and what it will not do.
The queue engine, both tokens, the room API, signing-key rotation, the estimated wait and the metrics are built and tested, and so is the TicketOps side. What remains is a live sale, a load test on the real host, API key rotation, and something reading the metrics.
Not yet in front of a live sale
QueueOps and its TicketOps integration are built and tested end to end. The rehearsal and the first real room are still to come, so every figure on this page is from tests and a load test, not from a sale.
The throughput target is not shown on the real host
The plan asks for 10,000 polls a second across three replicas. The load test ran on a busy laptop and did not reach it; a run on the production host with nothing else running is still to do.
Private, and accounts are made by hand
The source is private and there is no sign-up. An operator makes each platform’s account and hands over its key, which is how we would set yours up.
One Redis, and it fails closed
Every room lives in one Redis that writes each join to disk before answering. While it is unreachable, joins and polls are refused with a 503; buyers already admitted keep buying, because the platform checks their tokens itself.
A place is never given back
Everyone who joins counts against the capacity for good. When more people want in than there is room for, arrival decides who gets a place at all, and the shuffle orders only those who did. An admission that runs out is final.
No API key rotation yet
Signing keys rotate in stages without downtime, but a platform’s API key does not: replacing a leaked one means a new account, between sales.
Metrics with nothing reading them
Each replica serves its counters in Prometheus format and logs them once a minute, warning when the ticker runs late or Redis fills. Nothing scrapes them yet.
How it decides
Three replicas, one Redis, five scripts.
A Fastify service in TypeScript, run as three identical replicas in front of one Redis. Everything that decides who gets in runs as a Lua script inside Redis, so each decision is one atomic step and the replicas never have to agree with each other.
src/scripts
put-room.lua
Creates or replaces a room by version, so two saves at once can never leave the older one stored.
join.lua
Checks the room, its capacity and the address limit, then takes the place, in one step.
status.lua
Tells a visitor where it stands, after checking the nonce stored on its place.
exit.lua
Leaves the queue, or gives an admitted slot back early. The place is not returned.
advance.lua
Expires and admits. Every replica runs it every second, with no lock and no leader.
What holds across them
Redis' clock
Every queue script reads the time from Redis, so no two replicas can disagree about whether the queue or the sale is open.
Fail closed
With Redis unreachable, requests are refused with a 503 and a Retry-After. The service boots without Redis and turns healthy when it answers.
On disk
Redis writes every join before acknowledging it and refuses writes when full, rather than dropping places.
Accounts apart
Every route is scoped to the key’s account. Another platform’s room is simply a room that does not exist.
The contract
Eight routes, three ways to call them.
The platform’s server calls with its API key; a buyer’s browser calls with the visitor token the join handed it, through the platform’s own domain; the signing keys are public. The OpenAPI document is generated from the same zod schemas the handlers parse with, and a test fails while it is stale.
| Route | Called with | What it does |
|---|---|---|
PUT /v1/rooms/:roomId | API key | Create or replace a room |
GET /v1/rooms/:roomId | API key | The room and its counters |
DELETE /v1/rooms/:roomId | API key | Forget the room |
POST /v1/rooms/:roomId/entries | API key | Give a person a place, or hand back theirs |
GET /v1/accounts/me/signing | API key | The key that signs admissions now |
GET /v1/accounts/:accountId/jwks.json | public | Every published signing key |
GET /v1/public/visitor/status | visitor token | State, position, estimated wait, admission |
POST /v1/public/visitor/exit | visitor token | Leave, or give the slot back |
Read the source
Six excerpts from closed source.
QueueOps is private, and these are the only lines of it published: the join and the admission as Redis runs them, the draw that orders the crowd, how often a browser asks, and the two tokens.
queueops
src/scripts
src/queue
src/tokens
src/scripts/join.lua · 20–49
local cfg = redis.call('HMGET', K_CFG, 'status', 'queueOpensAt', 'saleOpensAt', 'capacity', 'maxPerClientKey', 'expiresAt') if not cfg[1] then return { 'err', 'room_not_found' } end if cfg[1] ~= 'active' then return { 'err', 'room_closed' } end if now < tonumber(cfg[2]) then return { 'err', 'room_not_open' } end local sale_opens_at, expires_at = tonumber(cfg[3]), cfg[6] local created = 0 local vid = redis.call('HGET', K_SUBJECTS, subject) local state if vid then local raw = redis.call('HGET', K_VISITORS, vid) state = raw and parse_visitor(raw) or 'l' redis.call('HSET', K_VISITORS, vid, visitor_value(state, nonce, subject)) else if redis.call('HLEN', K_SUBJECTS) >= tonumber(cfg[4]) then return { 'err', 'room_full' } end if cfg[5] ~= '' and tonumber(redis.call('HGET', K_CLIENTS, client_key) or '0') >= tonumber(cfg[5]) then return { 'err', 'client_limit' } end local score = random_score if now >= sale_opens_at then score = LATE_BASE + redis.call('INCR', K_SEQ) end vid, state, created = fresh_vid, 'w', 1 redis.call('HSET', K_SUBJECTS, subject, vid) redis.call('HINCRBY', K_CLIENTS, client_key, 1) redis.call('HSET', K_VISITORS, vid, visitor_value(state, nonce, subject)) redis.call('ZADD', K_QUEUE, score, vid) expire_with_room(expires_at, K_SUBJECTS, K_CLIENTS, K_VISITORS, K_QUEUE, K_SEQ) end
One script decides a join: the room’s checks in order, then the writes. Nothing runs between them, so concurrent joins can never take more places than the room has, or more than one client key is allowed.
src/scripts/advance.lua · 28–54
-- 1. Admissions whose time is up give their slot back. The visitor's place stays spent. local ran_out = redis.call('ZRANGEBYSCORE', K_ACTIVE, '-inf', now, 'LIMIT', 0, batch) for _, vid in ipairs(ran_out) do local raw = redis.call('HGET', K_VISITORS, vid) if raw then local state, nonce, subject = parse_visitor(raw) if string.sub(state, 1, 2) == 'a:' then redis.call('HSET', K_VISITORS, vid, visitor_value('x', nonce, subject)) end end redis.call('ZREM', K_ACTIVE, vid) end -- 2. Free slots go to the front of the queue, lowest score first. local admitted = 0 local free = tonumber(cfg[3]) - redis.call('ZCARD', K_ACTIVE) if free > 0 then local admitted_until = now + tonumber(cfg[4]) * 1000 local popped = redis.call('ZPOPMIN', K_QUEUE, math.min(free, batch)) for i = 1, #popped, 2 do local vid = popped[i] local raw = redis.call('HGET', K_VISITORS, vid) if raw then local _, nonce, subject = parse_visitor(raw) redis.call('ZADD', K_ACTIVE, admitted_until, vid) redis.call('HSET', K_VISITORS, vid, visitor_value('a:' .. int(admitted_until), nonce, subject)) admitted = admitted + 1 end end
The ticker’s step. Admissions that ran out give their slot back, then the front of the queue fills the free slots. It counts the slots in the same script that fills them, which is why every replica can run it every second with no lock.
src/queue/random.ts · 16–22
/** * A pre-sale join's place in the order: uniform over [0, 2^48), from the CSPRNG. Independent uniform keys sort into * a uniformly random order, and 2^48 makes a tie (which Redis would break by visitor id) vanishingly rare. Every * score stays exact in a double (below 2^53), in Lua and in the sorted set. Six CSPRNG bytes are exactly that range * (`crypto.randomInt` refuses a span of 2^48). */ export const randomScore = (): number => randomBytes(6).readUIntBE(0, 6);
The fair draw. Everyone who joins before the sale gets a uniformly random place in the order, drawn from the CSPRNG, so arriving first before the sale gives no edge.
src/queue/poll.ts · 26–57
/** * How long a visitor should wait before polling again (PLAN "Fairness, consistency and the capacity limit"): * about 30 s before the sale, about 3 s near the front, 10–15 s further back, each ±20 %. * * A pre-sale visitor whose next poll would come after the sale opens is told to come back just after it opens * instead, spread over `SALE_OPEN_SPREAD_MS`, so the crowd sees its positions soon but not all in the same second. */ export const pollAfterMs = ( view: VisitorView, room: { readonly maxActive: number; readonly msUntilSale: number }, random: () => number = Math.random, ): number => { let ms: number; switch (view.state) { case 'pre_sale': { ms = jitter(PRE_SALE_POLL_MS, random); if (room.msUntilSale < ms) ms = Math.max(room.msUntilSale, 0) + SALE_OPEN_SPREAD_MS * random(); break; } case 'waiting': { const near = view.position !== null && view.position <= 2 * room.maxActive; ms = jitter(near ? NEAR_FRONT_POLL_MS : FAR_POLL_MIN_MS + (FAR_POLL_MAX_MS - FAR_POLL_MIN_MS) * random(), random); break; } case 'admitted': ms = jitter(ADMITTED_POLL_MS, random); break; default: ms = jitter(SETTLED_POLL_MS, random); } return Math.max(Math.round(ms), MIN_POLL_GAP_MS); };
How often a visitor polls. Near the front it is every few seconds; far back or before the sale it is much less often, and every interval is jittered so a crowd that joined together does not poll together.
src/tokens/admission.ts · 55–67
async sign(admission: Admission): Promise<{ token: string; kid: string }> { const { kid, privateKey } = await this.key(admission.accountId); const token = await new SignJWT({ room: admission.roomId }) .setProtectedHeader({ alg: 'ES256', typ: 'JWT', kid }) .setIssuer(ADMISSION_ISSUER) .setAudience(admission.accountId) .setSubject(admission.subject) .setJti(admission.visitorId) // Rounded down: the token never outlives the admission. .setExpirationTime(Math.floor(admission.admittedUntil / 1000)) .sign(privateKey); return { token, kid }; }
The admission token: an ES256 JWT naming the platform’s account, the room and the person. It never outlives the admission, and the platform checks it against keys it has pinned.
src/tokens/visitor-token.ts · 59–74
/** The claims, or null for anything that is malformed, forged or expired. */ verify(token: string | null): VisitorClaims | null { if (!token || token.length > MAX_VISITOR_TOKEN_LENGTH || !TOKEN_SHAPE.test(token)) return null; const [body, signature] = token.split('.') as [string, string]; const given = Buffer.from(signature, 'base64url'); if (!this.secrets.some(secret => timingSafeEqual(this.mac(secret, body), given))) return null; let claims: unknown; try { claims = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')); } catch { return null; } if (!isClaims(claims) || claims.exp * 1000 <= this.now()) return null; return { acct: claims.acct, room: claims.room, vid: claims.vid, nonce: claims.nonce, exp: claims.exp }; }
A visitor token is checked by HMAC before Redis is touched, so a forged or garbage token costs only CPU. Even a valid one still needs the nonce stored on its place, which a fresh join replaces.
Measured
What a load test showed, and on what.
Measured on a laptop, with three replicas limited as the deploy stack limits them, while other work kept the machine busy. That shows the estimate well and the throughput hardly at all; a run on the production host is still to come.
Estimated wait
Within 4 s of the real wait, at the median.
Past a sale’s first five minutes, buyers waited 0.97 times their first estimate at the median, across 887 estimates. Inside the first five minutes they waited 1.03 times it. The estimate is a guide, and the storefront says so.
100,000 visitors
Every join and every poll answered.
100,000 joins and 232,507 polls on a machine kept at a load average of 41 to 45 by other work, and not one request failed. On an idle machine, one replica took 9,267 joins and 8,396 polls a second.
Memory
280 bytes a visitor.
Measured in Redis across 100,000 joins with short ids, and about 330 bytes with UUIDs. A million visitors fit inside the 1 GB Redis the stack runs.
Many rooms
300 sales opening together.
With 300 live rooms opening in the same second, a tick took 24 ms at the median and 298 ms at its worst.
The repository
One service, one image, one stack.
An ESM package on Node 22. The image copies only what it builds from and runs as an unprivileged user, and CI checks that inside it an account, a room and a join work, and that an acknowledged join survives Redis restarting.
queueops
src
deploy
docs
src/scripts/
The Lua scripts and the prelude they share, registered with Redis at start-up. Every decision about a place or a slot is made here.
src/queue/
The store over the queue scripts, the join and visitor routes, poll timing and the per-visitor limiter, the estimated wait, and the ticker.
src/tokens/
Visitor tokens, an HMAC with a nonce under rotating secrets, and admission tokens, ES256 through jose.
src/accounts/
API keys, kept only as hashes; signing keys, encrypted at rest; the account store; and the operator CLI that makes accounts and rotates keys.
src/rooms/
The zod schemas that are the contract, the room store and the room routes.
src/http/
API-key authentication, the error codes and their handler, and the OpenAPI builder.
src/server.ts
The Fastify app: the health check and the metrics, the authenticated routes, and the public ones a browser reaches.
src/config.ts
The environment, validated at start-up. The process refuses to boot without its secrets, and an error names the variable, never its value.
deploy/docker-compose.queueops.yml
The stack: three replicas and one Redis that only they can reach, deployed by CI from main, with new replicas healthy before old ones stop.
docs/api-v1.md
The contract between QueueOps and the platforms on it, with its OpenAPI twin generated beside it.
docs/load-test-2026-10.md
What the load test measured, on what, and what it does and does not show.
Where it stops
What it has not done yet, and what it will not do.
The queue engine, both tokens, the room API, signing-key rotation, the estimated wait and the metrics are built and tested, and so is the TicketOps side. What remains is a live sale, a load test on the real host, API key rotation, and something reading the metrics.
Not yet in front of a live sale
QueueOps and its TicketOps integration are built and tested end to end. The rehearsal and the first real room are still to come, so every figure on this page is from tests and a load test, not from a sale.
The throughput target is not shown on the real host
The plan asks for 10,000 polls a second across three replicas. The load test ran on a busy laptop and did not reach it; a run on the production host with nothing else running is still to do.
Private, and accounts are made by hand
The source is private and there is no sign-up. An operator makes each platform’s account and hands over its key, which is how we would set yours up.
One Redis, and it fails closed
Every room lives in one Redis that writes each join to disk before answering. While it is unreachable, joins and polls are refused with a 503; buyers already admitted keep buying, because the platform checks their tokens itself.
A place is never given back
Everyone who joins counts against the capacity for good. When more people want in than there is room for, arrival decides who gets a place at all, and the shuffle orders only those who did. An admission that runs out is final.
No API key rotation yet
Signing keys rotate in stages without downtime, but a platform’s API key does not: replacing a leaked one means a new account, between sales.
Metrics with nothing reading them
Each replica serves its counters in Prometheus format and logs them once a minute, warning when the ticker runs late or Redis fills. Nothing scrapes them yet.
Behind the project
Built by Eight Mile in London, as part of our software development work: the same engineers who build and run systems like it for clients.
Next step
Running on-sales that sell out in minutes?
QueueOps is ours, and it stays private. We run it as a service: your platform gets an account, opens a room for each sale and checks admissions itself, with no call back at checkout. TicketOps, the stadium ticketing platform we build, is the first one integrated with it. Tell us about your on-sales and we will tell you plainly whether it fits.