anteroom

module
v0.0.0-...-2af8660 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 5, 2026 License: MIT

README

anteroom

check Go Reference

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.

Try it

docker compose -f deploy/docker-compose.yml up --build

Open 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.

Who this is for

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.

Install

go install github.com/rsendz/anteroom/cmd/anteroom@latest

That 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/anteroom

Or run the image, which has both built in:

docker build -f deploy/Dockerfile --target anteroom -t anteroom .

anteroom --version reports which build you have.

Put it in front of your own site

One command, no configuration:

anteroom --origin http://localhost:3000 --rate 50

Then 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.yaml

Set 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.

How admission works

A visitor is let through only when both limits allow it:

  • Rate. A token bucket admits rate visitors 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_active visitors may be on the site at any time. A session is reclaimed after session_ttl without 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.

What it holds

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.

Scheduled drops

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   # optional

Before 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.

Rooms

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: 100

A 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.

The waiting page

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.

Events

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.

The control room

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.

Admin API

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/config

GET /__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.

Notes

  • 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 for fail_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: true on a room forwards the visitor's Host to the origin, for backends that serve several virtual hosts.

Building

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 tests

make 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.

Layout

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

Not included

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.

License

MIT. See LICENSE.

Directories

Path Synopsis
cmd
anteroom command
Command anteroom puts a fair waiting room in front of a website.
Command anteroom puts a fair waiting room in front of a website.
demo-origin command
Command demo-origin is a stand-in for a real website, used by the Docker Compose stack so that getting admitted through anteroom is visible.
Command demo-origin is a stand-in for a real website, used by the Docker Compose stack so that getting admitted through anteroom is visible.
internal
admit
Package admit runs the admission loop: the background ticker that decides, room by room, who leaves the queue and enters the site.
Package admit runs the admission loop: the background ticker that decides, room by room, who leaves the queue and enters the site.
config
Package config loads and validates anteroom configuration from a YAML file, with flat ANTEROOM_* environment variable overrides.
Package config loads and validates anteroom configuration from a YAML file, with flat ANTEROOM_* environment variable overrides.
events
Package events publishes what the waiting room did to an event stream.
Package events publishes what the waiting room did to an event stream.
httpserver
Package httpserver is anteroom's front door.
Package httpserver is anteroom's front door.
queue
Package queue holds the waiting-room state: who is waiting, in what order, who is currently admitted, and how fast admissions may happen.
Package queue holds the waiting-room state: who is waiting, in what order, who is currently admitted, and how fast admissions may happen.
token
Package token issues and verifies the HMAC-signed cookie that identifies a visitor across requests.
Package token issues and verifies the HMAC-signed cookie that identifies a visitor across requests.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL