A self-hosted virtual waiting room. Put it in front of a page that's about to get more traffic than it can take, and instead of the page falling over, visitors see a fair queue with their position and are let through at a rate the site can actually handle.
visitors ──▶ anteroom ──▶ your site
│
└─▶ waiting page: position, estimated wait, live updates
Anteroom is a reverse proxy, so the site behind it needs no changes at all: no SDK, no middleware, no code.
The number on the board physically turns over as the queue moves. That is the whole reason it is there: a visitor who can see the line advancing doesn't reach for reload, and reloading is the traffic anteroom exists to absorb.
docker compose -f deploy/docker-compose.yml up --buildOpen http://localhost:8080. The demo admits one visitor every two seconds with a limit of three on the site at once, so you can watch the queue work. Open a second browser (or a private window) to get in line behind yourself.
The control room is at http://localhost:8080/__anteroom/admin/. The demo
token is demo-admin-token.
Anyone who runs their own edge: a VPS, a container host, Kubernetes, a box under a desk. Anteroom is one binary and a Redis, and it needs to be the thing your traffic arrives at.
It cannot run on a serverless platform (Vercel, Netlify, Workers) because it is a long-lived process that holds a queue, not a function. It can happily sit in front of an origin hosted there, but that means running a server of your own and pointing DNS at it, which is a trade worth making deliberately.
go install github.com/rsendz/anteroom/cmd/anteroom@latestThat builds without the front-end assets, so anteroom serves a plain waiting page that shows the position and refreshes itself, and the control room falls back to its API. It is a real waiting room, just not the animated one.
For the split-flap board and the dashboard, build the front-end in:
git clone https://github.com/rsendz/anteroom && cd anteroom
make build # front-end, then bin/anteroomOr run the image, which has both built in:
docker build -f deploy/Dockerfile --target anteroom -t anteroom .anteroom --version reports which build you have.
One command, no configuration:
anteroom --origin http://localhost:3000 --rate 50Then send traffic to anteroom instead of your site. It prints an admin token on startup for the dashboard.
For anything you intend to keep running, start from a config file:
anteroom init > anteroom.yaml # writes a commented config with fresh secrets
anteroom --config anteroom.yamlSet ANTEROOM_COOKIE_SECRET (or cookie_secret) to a stable value. It signs
the cookie that remembers a visitor's place, so changing it sends everyone to
the back of the queue.
A visitor is let through only when both limits allow it:
- Rate. A token bucket admits
ratevisitors per second. Burst is capped at one second's worth, so a pause or an outage can't dump the queue on your origin all at once. - Concurrency. At most
max_activevisitors may be on the site at any time. A session is reclaimed aftersession_ttlwithout a request, and its slot goes to the next person in line.
Both are adjustable while running, from the dashboard or the API, and take effect on the next admission pass (250 ms by default).
The queue is strict FIFO, ordered by a Redis counter, and admission runs as a single atomic Lua script. Several anteroom replicas can share one Redis and still enforce one fair queue and one global rate.
Abandonment. A waiting page sends a heartbeat while it's open. A visitor
who closes the tab stops sending it, and after abandon_after they're dropped
from the queue so they aren't holding up the people behind them. If they come
back, they rejoin at the end.
Bot resistance. Each address may add only so many visitors to the queue per
minute (join_limit_per_ip, 120 by default), which stops a script taking
thousands of places. Anyone over the limit gets a 429. Keep it generous,
because office and mobile networks put many real people behind one address,
and watch total_refused on the dashboard, which is the signal that it's too
tight.
If anteroom runs behind a load balancer you must list it in
trusted_proxies, or X-Forwarded-For is ignored, every visitor looks like
the balancer, and they all share one budget. See
docs/production.md.
Measured, not estimated. These come from loadtest/, on one M1 laptop (8
cores, 8 GB) with Redis 7.4 in Docker and everything talking over loopback:
| Measured | |
|---|---|
| Queue depth | 1,000,000 waiting visitors |
| Memory at that depth | 229 MiB of Redis, and 62 MiB of anteroom |
| Joins | 19,000 a second sustained, p50 24 ms, p99 48 ms |
| Admissions | 20,000 a second, within 0.2% of the configured rate |
| Three replicas, one budget | 1,000 a second configured, 1,012 observed |
| Held position streams | 9,500 at once, p99 page load 1.1 ms |
Three of those are worth a word.
Anteroom holds 62 MiB with a million people queued, because it keeps no queue of its own. The queue is in Redis, which is what lets you run several replicas and restart any of them mid-spike.
The rate is a real limit, not a target. Over a 60 second window at a configured 1,000 a second, exactly 60,000 were admitted. The admission counter and the drop in queue depth agree to the visitor, so the number is not an artifact of where it was read.
Three replicas sharing one Redis admitted 1,012 a second, not 3,000. That is the atomic Lua script doing its job: replicas cannot double-admit or jointly exceed the rate.
The ceiling on held streams is this laptop, not anteroom: macOS caps a
process at 10,240 descriptors, and anteroom logs too many open files and
keeps serving rather than falling over. At roughly 28 KB per held stream, the
constraint on a real box is descriptors and memory, in that order. See
loadtest/README.md to run it yourself.
For a sale that starts at a fixed time, a room can open on a schedule:
rooms:
tickets:
origin: http://tickets-app:4000
lottery: true
schedule:
queue_opens_at: 2026-11-20T09:30:00Z # people may start lining up
admits_at: 2026-11-20T10:00:00Z # doors open
closes_at: 2026-11-20T12:00:00Z # optionalBefore queue_opens_at nobody is queued at all. Between then and admits_at
visitors are collected but nobody is let in. After closes_at no new
admissions happen, though visitors already on the site keep their sessions.
With lottery: true, everyone collected before the doors open gets a place
drawn from their identity rather than their arrival time, so turning up early
gains nothing. The place is derived by hashing, not randomly assigned, which
means leaving and rejoining lands on the same number, so there's no point
rerolling. Anyone arriving after the doors open queues behind the whole draw.
During the draw the page shows a countdown and how many have entered, not a position: a position would either shuffle as others join (which reads as broken) or reward whoever refreshed earliest.
One anteroom can protect several sites, chosen by hostname:
rooms:
shop:
match_host: shop.example.com
origin: http://shop-backend:3000
rate: 50
max_active: 500
tickets:
match_host: tickets.example.com
origin: http://tickets-app:4000
rate: 10
max_active: 100A room with no match_host is the catch-all for any host that doesn't match a
more specific room. At most one room can be the catch-all. Each room has its
own queue, rate, cap, and counters; nothing crosses between them.
Server-rendered, then updated over Server-Sent Events: one Redis read per
waiting visitor every two seconds, no matter how impatient they are. If the
stream drops, the page falls back to reloading itself on a jittered timer so a
crowd doesn't return in lockstep. With no JavaScript at all, a meta refresh
keeps it moving.
title and message on the room set what it says.
Anteroom publishes what it does to Kafka: visitor_joined,
visitor_admitted, visitor_abandoned, session_expired, config_changed.
{"type":"visitor_admitted","room":"shop","visitor_id":"9f2c…","ts":"2026-09-04T09:15:02.113Z"}This is strictly a side channel. Publishing never blocks an admission or a page
load, events are dropped (with a log line) rather than allowed to back up, and
anteroom runs exactly the same with kafka.brokers empty or the broker down.
Try docker compose stop kafka against the demo and watch the queue carry on.
Every room's counters, a sparkline of queue depth, and the controls that matter
during an incident (rate, concurrency, pause, and emptying the queue) at
/__anteroom/admin/. Changes take effect on the next admission pass, without a
restart.
All endpoints need Authorization: Bearer <admin_token>.
| Method | Path | Does |
|---|---|---|
GET |
/__anteroom/admin/api/status |
Queue health; answers even when Redis is down |
GET |
/__anteroom/admin/api/metrics |
Every room in the Prometheus text format |
GET |
/__anteroom/admin/api/rooms |
Every room with its counters |
GET |
/__anteroom/admin/api/rooms/{room}/stats |
One room's counters |
PUT |
/__anteroom/admin/api/rooms/{room}/config |
Change rate, max_active, session_ttl_secs, abandon_after_secs |
POST |
/__anteroom/admin/api/rooms/{room}/pause |
Hold everyone where they are |
POST |
/__anteroom/admin/api/rooms/{room}/resume |
Start admitting again |
POST |
/__anteroom/admin/api/rooms/{room}/flush |
Empty the queue (sessions on the site are left alone) |
curl -X PUT -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"rate": 100}' \
http://localhost:8080/__anteroom/admin/api/rooms/shop/configGET /__anteroom/healthz needs no token.
Metrics. admin/api/metrics is Prometheus exposition format: queue depth,
what's on the site, and the running totals, labelled by room, plus whether the
queue store is answering. It reads the same one-second statistics cache the
waiting page does, so scraping it costs no Redis round trip and it keeps
reporting the last known numbers through an outage. It sits behind the admin
token like everything else under admin/api/, because queue depth during a
drop is exactly what someone gaming it wants to know; see
docs/production.md for the scrape config.
Runtime settings live in Redis, not the config file, so a change you make here
survives a restart. Anteroom logs a warning when the live values differ from
the file; start it with --reseed to make the file win.
- Anteroom reserves the URL prefix
/__anteroom/for itself. Nothing under it is ever proxied. Everything else belongs to your site. - If Redis is unreachable, nobody is admitted. Waiting visitors are held on
the page and let in when it recovers, with no restart needed. Waving everyone
through would hand your origin the exact spike anteroom is there to prevent.
If you'd rather serve the site unprotected than serve nobody, set
fail_open: true; anteroom then proxies everyone through, but only after the queue has been unreachable forfail_open_after(30s by default), and it says so loudly in the logs and on the dashboard while it does. - Anteroom does not terminate TLS. Run it behind your load balancer and set
secure_cookies: true. See docs/production.md for nginx and ALB configuration, Redis persistence, and sizing. preserve_host: trueon a room forwards the visitor'sHostto the origin, for backends that serve several virtual hosts.
The front-end is embedded in the binary, so it's built first:
make build # front-end, then the Go binary, into bin/anteroom
make test # go test ./... -race
make check # tests, vet, gofmt, and the front-end type-check and testsmake check is what CI runs, so a green run locally means a green run there.
go build ./cmd/anteroom on its own works too. Without the front-end assets
it serves a plain waiting page that still shows the position and refreshes
itself.
| Path | What's in it |
|---|---|
internal/queue |
Redis data model and the Lua admission script, the correctness core |
internal/admit |
The background loop that runs admissions |
internal/httpserver |
Routing, the proxy, the waiting page, SSE, the admin API |
internal/token |
The signed visitor cookie |
internal/events |
The Kafka publisher |
web/ |
Waiting page (vanilla TS) and control room (React), built by Vite |
TLS, path-based room matching, and any client SDK. Anteroom is a proxy on purpose. Rooms are matched by host, so a room is a whole site rather than one route; that keeps the awkward cases (assets and API calls on other paths, and the ways round a gated path) from existing at all.
MIT. See LICENSE.


