Limits & capacity#
PacketRelay is one process with fixed ceilings and no external state. Every limit is either a compiled-in constant or a flag with a validated range, and every one of them fails closed — a connection is admitted or it is gone. Nothing degrades quietly, and nothing is queued for later.
This page collects every ceiling in one table, separates the two rate limiters that are routinely confused with each other, and covers the two ways a well-behaved client is still starved: a full outbound queue, and a session reaper that does not care whether the session is busy.
Every ceiling in one place#
Defaults are the shipped values. The container passes no flags at all — the
image has an ENTRYPOINT and no CMD — so every default below is what a
Docker or Railway deployment runs with until you add arguments.
| Limit | Default | Set by | On breach |
|---|---|---|---|
| WebSocket message and frame size | 64 KiB | --max-message-bytes (hard maximum 64 KiB) |
Enforced inside tungstenite; the exact wire behaviour is unverified — see below. |
| HTTP request head | 16 KiB | compile-time MAX_HTTP_HEAD_BYTES — main.rs:42 |
The connection is dropped with no response — main.rs:441-443. |
| Tungstenite read / write buffers | 16 KiB each | compile-time — main.rs:40, 418-419 |
— |
| Tungstenite max write buffer | 128 KiB | compile-time — main.rs:41, 420 |
— |
| Global concurrent connections | 128 | --max-connections |
Two different mechanisms, two different outcomes — see below. |
| Concurrent connections per IP | 80 | --max-connections-per-ip |
TCP stream dropped, no response — rate_limit.rs:114-116. |
| New connections per IP per window | 240 | --max-connect-rate-per-ip |
TCP stream dropped, no response — rate_limit.rs:107-109. |
| Connection-rate window | 60 s | --connect-rate-window |
— |
| Global sessions | 256 | --max-sessions |
Error frame server at capacity — session.rs:130-131. |
| Room members | 32 | --max-room-members |
Error frame room at capacity — session.rs:249-251. |
| Spectators per broadcast | 64 | --max-spectators |
Error frame spectator cap reached — session.rs:210-212. |
session_id and member_id length |
64 bytes | --max-id-bytes |
Error frame invalid session ID or invalid member ID. |
| Request head and first hello deadline | 5 s | --hello-timeout |
Dropped with no error frame — connection.rs:491-546. |
| Messages per connection per window | 3,000 | --max-messages-per-window |
Error frame message rate exceeded, then close. |
| Payload bytes per connection per window | 64 MiB | --max-bytes-per-window |
Error frame message byte rate exceeded, then close. |
| Message-budget window | 60 s | --message-rate-window |
— |
| Outbound queue depth per connection | 16 | --outbound-queue |
Nothing is dropped; the sender's fan-out blocks — see below. |
| Session TTL | 300 s | --session-ttl |
Session reaped if it also has no participants — session.rs:454-476. |
| Reaper cadence | 30 s | --reaper-interval |
— |
Ranges and validation for each flag are in Flags & environment.
Every violation is caught by clap at startup, so a bad value is a failed
launch rather than a surprise at run time — main.rs:212-266.
The 64 KiB frame ceiling is a hard maximum#
--max-message-bytes accepts 1024 to 65536 and nothing above it. The upper
bound is not a default that can be raised; a larger value exits with a
validation error, and a test pins that rejection — main.rs:39, main.rs:234,
test main.rs:527-542. The value sets tungstenite's max_message_size and
max_frame_size together — main.rs:417-422 — so there is no fragmented path
around it.
What tungstenite 0.26 puts on the wire when a peer exceeds
max_frame_size has not been observed on this codebase. Treat an oversized
frame as "the connection ends", and do not build a client that depends on
receiving a particular close code.
Content larger than the inline ceiling has no transport here. Encrypted
artifact references for larger payloads are Planned — BACKLOG.md:75-85.
The connection cap is enforced twice#
--max-connections sizes two independent mechanisms. They count different
things, they run at different points in a connection's life, and they fail in
visibly different ways. Reading one number in the flags and assuming one
mechanism is the usual cause of a confusing capacity report.
| Mechanism | Counts | Runs at | On exhaustion |
|---|---|---|---|
RateLimiter global counter |
Every accepted TCP connection, including /health and /ready probes and connections that never send a byte |
Accept, before a single byte is read — main.rs:360-369 |
The stream is dropped with no response at all — rate_limit.rs:117-119 |
Legacy route Semaphore |
Only connections that reached the WebSocket upgrade — main.rs:52-70, 409-412 |
After the request head is parsed and routed | HTTP 503 Service Unavailable, body route capacity reached\n |
Health probes consume limiter budget, because the limiter runs at accept before the request line exists — the relay cannot yet know that the connection is a probe. They never consume a semaphore permit, because they are answered and shut down on the Health or Ready route without an upgrade.
The practical consequence: with a busy health check and a low
--max-connections, the limiter can sit at capacity while the semaphore has
permits to spare. Clients turned away in that state receive nothing at all,
while a client that gets far enough to be refused a permit receives a clean
503.
A 503 means the relay is alive and full. Silence means the
relay is alive and refusing you at the door. They are different capacity
problems and they are tuned with the same flag.
Connection admission is a sliding window#
try_connect runs once per accepted connection and evaluates in a fixed order
— rate_limit.rs:92-125:
- Snapshot whether the global counter is already at capacity.
- Prune this IP's recorded attempt timestamps older than the rate window.
- If the remaining timestamps already fill the per-IP rate budget, reject with
connection rate exceeded. - Record this attempt.
- If the IP is at its concurrent cap, reject with
too many concurrent connections. - If the global snapshot said capacity, reject with
server connection capacity reached. - Otherwise increment both counters.
Step 4 sits before both capacity checks on purpose: a rejected attempt still
counts toward the rate budget, and a test pins it — rate_limit.rs:111-112,
test connection_rate_counts_rejected_attempts. Without that ordering a client
refused for concurrency could retry without limit and pay nothing for it.
The window slides. Timestamps are pruned individually against the window edge on every call, so a client that spends its budget does not get it back in one lump — it recovers one attempt at a time, one window after each attempt was made.
None of the three rejection reasons reaches the client. The TCP
stream is dropped and a warn! is written to the server log —
main.rs:365-369. A client that reconnects in a tight loop on failure spends
its own rate budget and converts a transient concurrency rejection into a
lasting rate rejection.
disconnect decrements the counters, prunes the IP's history, and removes the
entry entirely once it is idle — rate_limit.rs:128-149. The reaper also calls
prune_idle, so per-IP history expires on a timer rather than waiting for the
address to come back — rate_limit.rs:152-163.
The message budget is a fixed window#
The per-connection traffic budget is a different mechanism with different semantics from connection admission. Confusing the two produces the wrong mental model of when a client recovers.
| Property | Connection admission | Message budget |
|---|---|---|
| Scope | Per IP, plus a global counter | Per connection |
| Window | Sliding — timestamps pruned individually | Fixed — counters reset wholesale — rate_limit.rs:214-219 |
| Rejected work | Still charged | Not applicable; a breach ends the connection |
| Client feedback | None; the stream is dropped | An error frame, then close |
| Recovery | Gradual, one attempt at a time | All at once, at the next window boundary |
The budget is charged once per inbound text frame, using the frame's byte
length, before any role dispatch — connection.rs:346. Message count is
checked first, giving message rate exceeded; the byte total is checked second
with a saturating add, giving message byte rate exceeded —
rate_limit.rs:220-225. On either breach the error frame is sent and the
forwarding loop breaks, which closes the connection.
Charging before dispatch has three consequences worth knowing before you tune anything.
- Keepalive pings are charged. The exact envelope
{"type":"ping"}is recognised and dropped rather than forwarded —connection.rs:361,480-488— but the charge has already happened. A one-second keepalive costs 60 of the 3,000 messages in every window. - Frames that go nowhere are still charged. Spectator-to-host text frames
are discarded, because the broadcast forwarding path has no spectator branch
—
connection.rs:389-394— and they are charged on the way in. - Binary frames are never charged. They are silently discarded in the
forwarding loop and never reach the budget —
connection.rs:405. WebSocket Ping and Pong control frames are not charged either —connection.rs:397-399.
Backpressure has no timeout and no drop policy#
Each connection owns one bounded tokio::sync::mpsc channel, sized by
--outbound-queue and defaulting to 16 — connection.rs:167. Fan-out to a
room or a broadcast awaits the send to each recipient sequentially, with no
timeout, no deadline and no drop policy — connection.rs:383-394.
A full recipient queue therefore stalls the sender's whole fan-out: the sending
task blocks inside the send, later recipients wait behind the slow one, and the
sender stops reading its own socket while it waits. Nothing is dropped and no
error is raised, so the symptom is latency and stalled delivery rather than a
visible failure. A bounded-latency policy for slow consumers is Planned —
BACKLOG.md:89-99.
Raising --outbound-queue buys a deeper buffer before the stall, not an escape
from it. The ceiling is 256.
Session age is never refreshed by activity#
Every session records its creation instant and never touches it again —
session.rs:137-147. The reaper wakes every --reaper-interval and removes
sessions whose age exceeds --session-ttl and which currently look
unoccupied — session.rs:454-476:
| Kind | Reaped when |
|---|---|
| Bridge | No mobile peer is registered and age > TTL — even if the desktop socket is still open — session.rs:459-461 |
| Broadcast | No host, no spectators, and age > TTL |
| Room | No members, and age > TTL |
Because age is measured from creation, a bridge that has been relaying frames
for six minutes is exactly as old as an abandoned one. The bridge rule is the
sharp edge: a desktop holding a session open while it waits for a phone loses
that session at 300 seconds while still connected. Whether activity or
reconnection ought to refresh expiry is recorded as an open question —
BACKLOG.md:196-204.
Reaping is not the only removal path. A session is deleted the moment its last
participant unregisters — both bridge slots empty, a broadcast with no host and
no spectators, or an emptied room — session.rs:412-414, 439-441. The reaper
exists for sessions that were created and then abandoned before anyone
registered, which is exactly what a leaked spectator_hello or a
desktop_hello that never finds a peer produces.
The global session ceiling is enforced atomically under the same
write lock that performs creation, so concurrent creators cannot race past it
— session.rs:130-132, test session.rs:749-784. The 257th session gets
server at capacity rather than a corrupted count.
Deliberate absences#
None of these is scheduled work you can wait for, and none of them has a client-visible signal.
| Absent | Consequence |
|---|---|
Post-hello idle timeout — connection.rs:333-409 |
A connection that completes its hello and then goes silent is held until the peer or the network ends it. |
Server-initiated keepalive — connection.rs:397-399 |
The relay never sends a WebSocket Ping; it only answers one with a Pong. Liveness detection is the client's job. |
| Drop policy for slow consumers | See backpressure above. Planned — BACKLOG.md:89-99. |
Graceful shutdown — main.rs:357-381 |
The accept loop is unconditional and no termination signal is handled. Termination kills in-flight connections and every session with them. Planned — BACKLOG.md:161-166. |
| Metrics export | Active connections, sessions, rejections and queue pressure cannot be observed from outside the process. Planned — BACKLOG.md:169-176. |
What to change first#
| Symptom | Flag to look at |
|---|---|
| Clients behind one office NAT are refused silently | --max-connections-per-ip, --max-connect-rate-per-ip |
| Reconnect storms turn into lasting rejections | --connect-rate-window, and the client's retry backoff |
503 route capacity reached under normal load |
--max-connections |
server at capacity when a session is created |
--max-sessions |
A near-idle client is closed with message rate exceeded |
--max-messages-per-window, and the keepalive interval |
| Fan-out latency spikes when one peer is slow | --outbound-queue, as mitigation rather than a fix |
| Sessions disappear while a desktop waits for a phone | --session-ttl |
Related#
- Flags & environment — every flag with its type, default and valid range.
- Troubleshooting — which of these limits produced the symptom in front of you.
- Security model — what the relay checks before any of these caps apply.
- How a connection works — where in a connection's life each limiter runs.
- HTTP endpoints — the health and readiness routes that consume limiter budget.