You need: matchmaking composed.
Two platform facts everything here inherits
Both are Durable Objects facts. The waiting queue is what runs in one, getting players into a session is the cheaper alternative, and the reference has the dials.
WebSocket messages bill at a 20:1 ratio. Twenty inbound messages bill as one request. A cost model that counts every frame as a request overstates a chatty session by up to twenty times.
Duration bills the full 128 MB, and only while an object is awake. The allocation is fixed, so there is no saving to chase in a smaller state — the saving to chase is hibernation.
The presence object costs nothing to hold
One Durable Object holds every online player’s WebSocket through the Hibernation API.
A hibernating object bills no duration, so a thousand players sitting in a menu with the app open cost nothing to hold. You pay for the events that actually flow, discounted 20:1.
That is only true because of what the object refuses to do:
- No in-memory connection registry — the live set is read back from the platform.
- The authenticated identity is stashed on the socket, not held in a field.
- No
accept(), and no timer.
It is one shared object, so its ceiling is a single object’s: a soft ~1,000 requests per second. Notifications do not approach that, but it is the number to plan against. A throughput ceiling, not a bill.
The queue object bills for waiting, and sweepSeconds is the dial
The open queue is one Durable Object per game, and it is the only thing here that wakes up on its own.
While anyone is waiting, a single alarm is armed at now + sweepSeconds. It wakes, re-attempts pairing, widens every waiting player’s band, and re-arms. Each wake is a request plus a short handler’s duration.
sweepSeconds | Wakes per waiting minute, per game | What it buys |
|---|---|---|
| 1 | 60 | Bands widen almost continuously. Pairing latency is dominated by who arrives, not by the sweep |
| 5 (default) | 12 | A player waits at most 5 seconds past the moment their band grew wide enough |
| 15 | 4 | A fifth of the default’s wakes. Noticeable on a thin queue, where the band is what unblocks the match |
An empty queue clears its alarm
The handler deletes the alarm the moment the waiting list drains, and the last player to leave deletes it too.
So a game nobody is queueing for wakes zero times and bills nothing, whatever sweepSeconds says.
That is why the table above is a per-waiting-minute rate and not a standing charge.
Two things that do not change with the dial
A match forms on enqueue when an opponent is already waiting — without waiting for a sweep.
Storage is read fresh and Zod-parsed per handler by design. An eviction costs a read; holding state in memory to avoid it would cost the hibernation that makes the whole object cheap.
Choosing a number
Start at the default. Five seconds is a latency nobody notices and twelve wakes a minute is nothing.
Raise it on a thin queue only if the wakes show up on your bill — and know that on a thin queue the band is what unblocks the match, so a slower sweep is a slower match.
Lower it only if pairing latency is genuinely the complaint, and remember that most of the wait is who arrives, not when you look.