Deploy a waiting room in front of a Next.js route on Vercel. This repo shows how to keep the protected-route hot path cheap with signed admission tokens, use Redis only for queue transitions, and preserve the original destination through the full queue flow.
- Provider-agnostic: Upstash Redis, self-hosted Redis (ioredis), or in-memory (dev)
- Signed admission token verified locally in
proxy.ts - FIFO queue with monotonic ticket ordering
- Atomic join-or-admit via Lua scripts (no thundering herd)
- Bounded Redis operations on hot paths
- Fail-open circuit breaker (site stays up if Redis is down)
- Next.js 16 proxy.ts (replaces middleware.ts)
- Session renewal via
waitUntil()with infrequent Redis refresh - Waiting-room shell that renders before queue status is fetched
- Adaptive client polling with jitter
- Namespaced Redis keys (multi-tenant safe)
- Preserves the original destination through the full queue flow
If you are reading this repo as a reference architecture, start with these files:
src/proxy.ts— verifies signed admission locally and only renews sessions occasionallysrc/lib/waiting-room/admission-token.ts— HMAC-signed admission token minting and verificationsrc/lib/waiting-room/service.ts— the shared decision layer used by Proxy, routes, and pagessrc/app/api/waiting-room/init/route.ts— mints the stable queue identity and redirects into the waiting roomsrc/app/api/waiting-room/status/route.ts— the queue transition endpoint that can admit and mint a tokensrc/lib/waiting-room/providers/*.ts— provider implementations and atomic Redis admission logicsrc/app/waiting-room/*— the waiting room shell and adaptive polling client
flowchart TD
A["Request for /demo"] --> B["src/proxy.ts"]
B --> C{"Valid admission token?"}
C -->|Yes| D["Protected route renders"]
C -->|No| E["/api/waiting-room/init?next=/demo"]
E --> F["Set stable identity cookie"]
F --> G["Redirect to /waiting-room?next=/demo"]
G --> H["Static waiting-room shell renders"]
H --> I["QueuePositionClient polls /api/waiting-room/status"]
I --> J{"Front of queue + capacity?"}
J -->|No| K["Return queued status"]
J -->|Yes| L["Mint admission token"]
L --> M["Redirect back to /demo"]
style B stroke:#66f,stroke-width:3px
style E stroke:#f9f,stroke-width:3px
style I stroke:#6f6,stroke-width:3px
style K stroke:#f59e0b,stroke-width:3px
src/proxy.tsstays lean. The common admitted path is local token verification, not a Redis round-trip.src/lib/waiting-room/admission-token.tsisolates the stateless admission model from queue coordination concerns.src/lib/waiting-room/service.tsowns state transitions. Proxy, route handlers, and Server Components all reuse the same decisions instead of reimplementing queue logic in multiple places.src/app/api/waiting-room/init/route.tsis the single place that creates queue identity and clears stale admission state.src/lib/waiting-room/providers/*.tshide backend differences behind one provider interface while keeping queue operations atomic.src/app/waiting-room/*is UI only. The shell renders quickly and the client adapts its polling cadence based on queue state.
-
Clone the repository.
-
Install dependencies:
pnpm install -
Configure environment variables:
cp .env.example .env.local -
Start the development server:
pnpm dev -
Open http://localhost:3000.
These files are the core waiting-room architecture you would copy into another app:
src/proxy.tssrc/lib/waiting-room/admission-token.tssrc/lib/waiting-room/types.tssrc/lib/waiting-room/config.tssrc/lib/waiting-room/edge-config.tssrc/lib/waiting-room/cookies.tssrc/lib/waiting-room/index.tssrc/lib/waiting-room/service.tssrc/lib/waiting-room/providers/*.tssrc/lib/waiting-room/lua/try-admit.tssrc/app/api/waiting-room/init/route.tssrc/app/api/waiting-room/status/route.ts
These files exist to make the demo easier to understand locally, but they are not required for a production waiting room:
src/lib/waiting-room/demo-simulation.tssrc/app/api/waiting-room/stats/route.tssrc/app/live-mode-panel.tsxsrc/app/demo-traffic-controls.tsxsrc/app/demo-mode-tabs.tsxsrc/app/(protected)/demo/page.tsxsrc/app/(protected)/purchase-button.tsxsrc/app/(protected)/session-footer.tsx
| Variable | Description | Default |
|---|---|---|
WAITING_ROOM_PROVIDER |
Backend provider: "upstash", "ioredis", or "memory" | memory |
WAITING_ROOM_TOKEN_SECRET |
HMAC secret for signing admission tokens | required in production |
WAITING_ROOM_CAPACITY |
Max concurrent active users | 100 |
WAITING_ROOM_SESSION_TTL_SECONDS |
Active session duration (seconds) | 300 |
WAITING_ROOM_QUEUE_TTL_SECONDS |
Abandoned queue entry purge time (seconds) | 1800 |
WAITING_ROOM_NAMESPACE |
Redis key namespace | default |
WAITING_ROOM_FAIL_OPEN |
Allow traffic if Redis is unavailable | true |
For production deployments on Vercel, you can use Edge Config to tune the waiting room without redeploying. Edge Config reads are served from memory on Vercel — zero latency cost.
Setup:
- Create an Edge Config store in your Vercel project settings.
- The
EDGE_CONFIGconnection string is auto-injected by Vercel. - Add any of the keys below to your Edge Config store to override the corresponding env var:
| Edge Config Key | Type | Overrides |
|---|---|---|
waitingRoomCapacity |
number | WAITING_ROOM_CAPACITY |
waitingRoomSessionTtlSeconds |
number | WAITING_ROOM_SESSION_TTL_SECONDS |
waitingRoomQueueTtlSeconds |
number | WAITING_ROOM_QUEUE_TTL_SECONDS |
waitingRoomFailOpen |
boolean | WAITING_ROOM_FAIL_OPEN |
Example Edge Config payload:
{
"waitingRoomCapacity": 100,
"waitingRoomSessionTtlSeconds": 300,
"waitingRoomQueueTtlSeconds": 1800,
"waitingRoomFailOpen": true
}Only these flat top-level keys are read by the waiting room. Generic example
keys like {"greeting":"hello world"} are ignored by this app. You can store
just the keys you want to override; missing keys fall back to env vars and then
to the built-in defaults.
Precedence: Edge Config → env vars → hardcoded defaults.
What stays in env vars only: Secrets (KV_REST_API_URL, KV_REST_API_TOKEN, UPSTASH_REDIS_REST_URL, UPSTASH_REDIS_REST_TOKEN, KV_URL, REDIS_URL, WAITING_ROOM_TOKEN_SECRET) and deploy-time decisions (WAITING_ROOM_PROVIDER, WAITING_ROOM_NAMESPACE) are never read from Edge Config.
Edge Config is optional — the waiting room works identically with just env vars.
Recommended for serverless environments (Vercel).
- Env Vars:
KV_REST_API_URL,KV_REST_API_TOKENfrom the Vercel Marketplace / Vercel KV integration, or the legacyUPSTASH_REDIS_REST_URL,UPSTASH_REDIS_REST_TOKEN - Setup: Set
WAITING_ROOM_PROVIDER=upstash.
For self-hosted Redis instances.
- Env Vars:
REDIS_URLorKV_URL - Setup: Set
WAITING_ROOM_PROVIDER=ioredis.
For local development only.
- Setup: Set
WAITING_ROOM_PROVIDER=memory. Throws an error in production.
proxy.ts: Verifies signed admission tokens locally and only renews active sessions when they are nearing expiry.- Admission token: Keeps the common admitted path stateless and cheap.
- Lua Script: Executes atomic join-or-admit operations in Redis, ensuring FIFO ordering and preventing race conditions.
- Session tracking: Uses expiry-scored sorted sets instead of scanning active-session hashes.
- Queue heartbeat cleanup: Drops stale queue entries lazily from the front so abandoned users do not permanently block admission.
- Adaptive polling: The client polls quickly near the front of the queue and more slowly when the user is far away.
This repository is intentionally optimized for a cheap admitted path and economical queue coordination. That means a few stronger guarantees are deliberately out of scope in the current implementation.
- Queue identity is continuity, not strong identity: The stable waiting-room cookie is what preserves a user's place in line. That keeps the design simple and cheap, but it also means the queue identity behaves more like a bearer credential than a full auth session. A stronger design would bind queue identity to more server-side state or extra device signals, which adds more reads, writes, and operational complexity.
- Fairness begins on first status contact: The current flow does not allocate a Redis ticket during
/api/waiting-room/init; the ticket is allocated on the first/api/waiting-room/statuspoll. That is cheaper because every redirected miss does not immediately become a Redis write, including bots, refresh churn, and users who bounce. The stricter alternative is closer to "arrival-time fairness," but it makes queue join cost scale with every protected-route miss. - Token renewal is optimistic: Near expiry,
src/proxy.tsrefreshes the cookie immediately and renews Redis state in the background. That keeps admitted traffic fast, but it can temporarily oversubscribe capacity if the background renewal fails after the new token is minted. The stricter alternative is to block on renewal or re-check Redis more often, which pushes network cost and latency back into the hot path. - Fail-open is an availability choice, not a fairness choice: The default behavior favors keeping the site reachable when Redis is unavailable. That is often the right tradeoff for a marketing launch, but it is the wrong default for hard inventory ceilings. A fail-closed posture is safer for scarce goods, but it also turns provider incidents into customer-visible lockouts.
- Queue admission is not purchase authority: This repo controls access to the protected page. It does not create an inventory hold, serialize checkout, or make downstream writes idempotent. If the protected experience sells scarce inventory, you still need separate reservation, idempotency, and anti-bot controls after admission.
The deeper tradeoff analysis in docs/waiting-room-scale-architecture.md explains why these lighter guarantees can be much cheaper at launch scale than the stricter alternatives.
The waiting room should be one layer in a larger launch posture, not the only line of defense.
- Filter abuse before the queue pays for it: Put Vercel Firewall in front of the waiting room and enable the managed protections that fit your traffic profile.
- Treat queue churn as suspicious traffic: Add Firewall rules for
src/proxy.ts's protected paths plus/api/waiting-room/initand/api/waiting-room/status. Preferchallengeorrate_limitactions for suspicious retries so bots do not turn queue polling into an application cost amplifier. - Use BotID on protected mutations, not as a queue replacement: Keep
proxy.tsas the GET-side gate, then add BotID to expensive POST routes or Server Actions after admission, such as checkout, reservation, or promo-claim flows. - Keep an incident switch ready: Use Edge Config for capacity and fail-open tuning, and keep Attack Challenge Mode as an emergency control for active launch abuse.
- Make trusted traffic explicit: Internal webhooks, staff tools, synthetic monitoring, and operational IP ranges should bypass the queue intentionally rather than competing with real users.
The deeper architecture notes in docs/waiting-room-scale-architecture.md include a route-by-route recommendation for how these layers fit together.
src/
├── proxy.ts # Protects the demo route
├── app/
│ ├── api/waiting-room/init/route.ts # Mints queue identity and redirects into the waiting room
│ ├── api/waiting-room/status/route.ts # Polling endpoint for queued browsers
│ ├── api/waiting-room/stats/route.ts # Demo-only landing page stats
│ ├── waiting-room/ # Waiting room page and polling client
│ └── (protected)/demo/page.tsx # Example protected route behind the waiting room
└── lib/waiting-room/
├── config.ts # Runtime config loading and caching
├── cookies.ts # Cookie contracts and redirect helpers
├── demo-simulation.ts # Demo-only queue pressure helpers
├── edge-config.ts # Edge Config overrides for runtime knobs
├── index.ts # Provider selection and lazy construction
├── service.ts # Shared waiting room state machine
├── types.ts # Status unions, provider interfaces, config types
├── lua/try-admit.ts # Atomic Redis admission script
└── providers/ # Upstash, IORedis, and memory providers
- Vercel: Use the Upstash Redis provider for seamless integration.
- Self-hosted: Use the IORedis provider with your own Redis instance.