Resource limits
Every limit has a safe default. The relay refuses to start if a limit is 0 (except where 0 explicitly disables a cap) or out of range. There is no “unlimited” setting.
Configurable limits
Section titled “Configurable limits”All keys go in [server], except the two under [server.offline_queue].
| Key | Default | Allowed | Limits | When exceeded |
|---|---|---|---|---|
max_connections | 10000 | ≥ 1 | Simultaneous TCP connections, counted from accept() | New connection closed immediately |
max_connections_per_ip | 64 | 0 – max_connections (0 = off) | Connections from one IPv4 address or one IPv6 /64 | New connection closed immediately |
max_connections_per_ipv6_48 | 256 | 0 – max_connections (0 = off) | Total connections from one IPv6 /48 | New connection closed immediately |
rate_limit_bps | 1048576 | ≥ 1024 | Inbound bytes per second per connection | Connection throttled, not closed |
max_message_bytes | 4194304 (4 MiB) | 65536 – 67108864 | Largest WebSocket message or frame from a client | Connection closed |
connection_timeout_secs | 300 | 60 – 86400 | Time a connection may send nothing | Connection closed |
identification_timeout_secs | 15 | 1 – 300, and ≤ connection_timeout_secs | Time a new connection may go without proving an identity (a hello alone does not count) | Connection closed |
max_pending_circuit_targets | 18000 | 1 – 1000000 | Pending conversation requests relay-wide, named and blinded alike. Each costs about 1.6 KB of memory (about 29 MB at the default; about 1.6 GB at 1,000,000) | Lighter source evicts the heaviest source’s oldest, or RateLimited |
pending_identity_reserve_percent | 25 | 0 – 90 | Share of max_pending_circuit_targets kept for named requests (first contact, the daily fallback); blinded requests never hold more than the rest | Blinded request refused (or the heaviest blinded source’s oldest evicted); a named request under the reserve evicts a blinded one |
max_pending_per_connection | 32 | 1 – max_pending_circuit_targets | Distinct targets one connection may have pending within 10 minutes; named requests only | Request refused |
max_pending_blind_per_connection | 1024 | 1 – max_pending_circuit_targets | Blinded requests one connection may have pending within 10 minutes, counted apart from named ones; about 4 per offline contact | Request refused (RateLimited) |
blind_ask_burst | 64 | 1 – 65536 | Blinded requests under new codes one connection may send at once (a token bucket; asking again under a code it holds is free) | Request refused (RateLimited) |
blind_ask_refill_per_sec | 8 | 1 – 10000 | Refill rate of that bucket, new codes a second; the app sends at most 32 at once, then 8 a second | Request refused (RateLimited) |
max_pending_per_source | 1024 | 1 – max_pending_circuit_targets | Pending named requests from one source | Request refused |
max_pending_blind_per_source | 4096 | 1 – max_pending_circuit_targets | Pending blinded requests from one source, counted apart from named ones | Request refused (RateLimited) |
max_circuits_per_connection | 256 | 1 – 65536 | Circuits one connection has created, and circuits one identity is a member of across reconnects | Request refused |
max_circuits | 200000 | 2 – 10000000 | Circuit routing entries in the whole server (two per pair) | Lighter source evicts the heaviest source’s newest pair, or RateLimited |
max_circuits_per_source | max_circuits / 64 (3125) | 2 – max_circuits | Circuit sides held by identities that asked from one source | Request refused |
offline_queue_max_total_bytes | 268435456 (256 MiB) | ≥ per-recipient cap | Bytes in the offline queue across all recipients | Message refused, not acknowledged |
offline_queue_max_bytes_per_recipient | 16777216 (16 MiB) | ≥ max_message_bytes | Offline bytes queued for one recipient | Message refused, not acknowledged |
tls_handshake_timeout_secs | 10 | 1 – 120 | Time to finish the TLS handshake | Connection dropped |
outbound_budget_bytes | 1073741824 (1 GiB) | 16 MiB – 64 GiB | Bytes queued for sending across all connections | Heaviest connection of the heaviest source closed |
buffer_budget_bytes | 536870912 (512 MiB) | max_message_bytes – 64 GiB | WebSocket buffer bytes across all connections | Heaviest connection of the heaviest source closed |
[server.offline_queue] ttl_secs | 259200 (72 h) | 3600 – 259200 | Lifetime of a queued message (72 h is the maximum) | Message deleted |
[server.offline_queue] knock_budget_bytes | 16777216 (16 MiB) | Bytes of queued message requests, separate from messages | Oldest request of the heaviest source evicted |
At startup the relay logs the main values: Limits: max_connections=..., max_connections_per_ip=..., rate_limit_bps=..., max_message_bytes=..., connection_timeout_secs=....
Clients accept frames from the relay up to 4 MiB + 64 KiB, sized for the default max_message_bytes. Raising max_message_bytes does not let users send larger messages.
Connection caps
Section titled “Connection caps”max_connections counts every connection from the moment it is accepted, including the TLS handshake, the HTTP upgrade and decoy responses. Connection N+1 is closed before any TLS or HTTP work. Keep the file-descriptor limit (LimitNOFILE in the unit) well above this.
max_connections_per_ip groups IPv4 connections by address and IPv6 connections by /64 prefix. The per-address table holds only a keyed hash of each address while its connections are open, and addresses are never logged. Behind a TCP front end, set both per-address caps to 0 and cap clients in the front end.
Each connection may send 6 hellos or key publishes, then one more every 10 seconds. Beyond that it is closed (reason="auth_rate").
Per-source fair shares
Section titled “Per-source fair shares”A source is an IPv4 address or an IPv6 /48. Everyone behind one source shares one allowance:
max_pending_per_sourcepending named conversation requests and, separately,max_pending_blind_per_sourceblinded ones, so blinded requests never crowd out first contacts. When the relay-wide table is full, a request from a lighter source evicts the oldest request (of either kind) of the heaviest source (pending_evicted).max_circuits_per_sourcecircuit sides. When the circuit table is full, a new pair from a lighter source tears down the heaviest source’s newest pair (circuit_evicted). Both people in that pair getCircuitClosedand pair again on their next request.- At most 1/16 of the message-request budget, and 1/16 of the 24-hour table of new requests per asker.
These shares are off when max_connections_per_ip = 0, because every connection then looks like one address, unless proxy_protocol = true supplies the client’s address from a TCP front end.
Carrier-grade NAT. Many legitimate users can share one IPv4 address. That address can then be the source that is shed or evicted, and its users share one fair share. For relays with many such users, raise max_connections_per_ip, max_pending_per_source, max_pending_blind_per_source and max_circuits_per_source.
Sizing max_pending_circuit_targets. Blinded requests fill most of the relay-wide table on a busy relay. Plan for online users × offline contacts each × 2 to 4: a phone waiting for its contacts holds two to four per offline contact (one code or two under the current 10-minute slot, and the same under the previous slot for a while after it changes), and the relay can’t tell they belong to one pair. A relay with a few hundred online users who each have many offline contacts reaches the default 18,000. Pairing then slows rather than fails (refused requests get RateLimited, and the app backs off and asks again). Alert on a steady rate of rchat_rejections_total{reason="pending_evicted"}, for example rate(rchat_rejections_total{reason="pending_evicted"}[15m]) > 0.1: the table is too small for your users. Raise it on larger relays with the memory for it: 100,000 is about 160 MB.
Blinded requests can’t crowd out first contact. Each connection’s new blinded requests are paced (blind_ask_burst, blind_ask_refill_per_sec), blinded requests never hold more than 75% of the table (pending_identity_reserve_percent), and a named request that finds the table full while named requests hold less than their reserve evicts a blinded one. This holds even when per-source shares are off.
Without source accounting (a front end with no PROXY v2) the blinded share itself is not protected: a few connections, about 14 at the defaults, can fill it and block new blinded pairing relay-wide until their requests expire. Named requests stay protected by the reserve. Use proxy_protocol = true with a PROXY v2 front end, or expose the relay directly.
Rate limit
Section titled “Rate limit”Each connection has a token bucket over the bytes it sends:
- It refills at
rate_limit_bpsbytes per second, despite the name. - The burst size is
max(rate_limit_bps, max_message_bytes), so one maximum-size message always fits. - Each message costs its length plus 256 bytes, so floods of tiny frames are limited too. At the default rate that is about 4096 messages per second.
A connection over budget is throttled, not disconnected. The relay stops reading from it until the bucket refills, and TCP flow control slows the client down.
Timeouts
Section titled “Timeouts”- Idle: a connection that sends nothing for
connection_timeout_secsis closed. Clients send a keepalive every 22 to 38 seconds, so connected apps are not affected. - Identification: a new connection must complete a signed key publish within
identification_timeout_secs. - Keepalive: the relay pings every 25 to 35 seconds and closes a connection whose pong is more than 60 seconds late, counted from when the ping was written to the socket, not from when it was queued.
- Upgrade: a client must send complete HTTP request headers within 5 seconds and finish the WebSocket upgrade within 10 seconds. Request headers are capped at 8 KiB.
Circuits and requests
Section titled “Circuits and requests”A circuit opens only when both people have asked for it, and lives while both endpoints are connected. It ends when either disconnects or a member closes it. There is no time-based expiry. A one-sided request is held for up to 10 minutes. A first request names its target and carries a message request (a fixed 5120-byte “knock”), which is queued for the recipient. Later requests between the same two people are blinded: they carry a token instead of a target and no knock, and are the same size on the wire. A blinded conversation ends at once when its last member leaves, within 10 minutes after either member’s connection closes, and when a member comes back on a new connection.
The message-request store has its own budget (knock_budget_bytes), never charged to messages. Beyond the per-source share: at most 200 per recipient, one per pair (a newer one replaces it), and 100 new ones per asker per 24 hours. Requests expire after at most 72 hours.
Fixed limits
Section titled “Fixed limits”These cannot be configured.
| Limit | Value |
|---|---|
| Outbound queue per connection | 256 messages and 16 MiB |
| Wait for space in a recipient’s queue | 5 seconds, then the recipient is disconnected as stuck |
| Writer stall | 30 seconds with nothing written while a message waits, then the connection is closed |
| Offline messages per recipient | 1000 |
| Steady-size (1,226-byte) offline messages per recipient | 500 (half the queue) |
| Messages tracked for delivery | 100,000 |
Slow consumers and memory budgets
Section titled “Slow consumers and memory budgets”When a recipient stops reading, relayed messages use backpressure. The sender waits up to 5 seconds for queue space, and the relay stops reading from the sender meanwhile. If no space opens, the recipient is disconnected and the message goes to the offline queue. Server-generated messages (acknowledgements, pings) cannot wait; if a recipient’s queue is full for those, it is disconnected immediately.
When the relay-wide outbound budget (outbound_budget_bytes, 1 GiB by default) or WebSocket buffer budget (buffer_budget_bytes, 512 MiB by default) is spent, the relay closes the heaviest connection of the source holding the most (outbound_shed, buffer_shed).
Offline queue and acknowledgements
Section titled “Offline queue and acknowledgements”A MessageAck means the relay has accepted the message: it is queued for a connected recipient, or held for an offline one. The caps are checked before acknowledging. A message that does not fit is refused: the sender gets a RateLimited error and no acknowledgement. Queued messages are never dropped to make room.
Queued items are held in memory by default for ttl_secs (72 hours). See Offline queue for opt-in encrypted persistence.
An accepted contact can fill a recipient’s offline queue (1000 messages or 16 MiB) while the recipient is offline. Other senders are then refused until the recipient reconnects or messages expire. They find out immediately, because they get no acknowledgement.
Steady traffic
Section titled “Steady traffic”Clients with steady traffic on send one 1,299-byte packet (on the TLS stream) every 0.5 s by default while the app is open, about 2.6 KB a second: about 0.3% of the default rate_limit_bps, so the defaults need no change. 10,000 clients in steady mode are about 26 MB/s of relay traffic. A steady client stops sending to a contact that has not answered for 64 packets, and steady-size messages may fill at most half of anyone’s offline queue; about 8 steady contacts of someone who vanished can fill that half, after which their 1 KiB messages to that person are refused (and kept by the senders) until the person returns.
Panic containment
Section titled “Panic containment”The relay’s release build uses unwinding panics with overflow checks. A fault in one connection closes only that connection (task_panic), and the relay keeps serving everyone else.
Metrics
Section titled “Metrics”With [metrics] enabled = true (the default), the relay serves Prometheus metrics on 127.0.0.1:9090:
curl -s http://127.0.0.1:9090/metrics | grep '^rchat_'The metrics are aggregate counters only. Every metric is named rchat_* (or the standard process_*), and the only label is reason. They carry no identifiers or addresses.
The endpoint has no authentication. Keep it on loopback and reach it over an SSH tunnel. A non-loopback bind_addr stops the relay at startup unless [metrics] allow_public = true; then firewall it. At most 8 connections are served at once, each with a 5-second header and 30-second total timeout. If it cannot bind, the relay logs Metrics endpoint failed on ... and keeps relaying.
rchat_rejections_total{reason=...}:
| Reason | Meaning |
|---|---|
max_connections | Connections refused at the global cap |
per_ip_cap | Connections refused at the per-address cap |
per_prefix_cap | Connections refused at the IPv6 /48 cap |
rate_limit | Read pauses for exceeding rate_limit_bps |
oversized_frame | Connections closed for exceeding max_message_bytes |
handshake_timeout | Connections closed for not finishing the WebSocket upgrade |
idle_timeout | Connections closed for idleness |
identification_timeout | Connections closed for not proving an identity in time |
auth_rate | Connections closed for exceeding the hello and publish budget |
key_auth | Publishes refused: bad bundle signature, or a connection proof that does not match this TLS session |
pending_targets | Requests refused at the pending caps |
pending_evicted | A heavy source’s pending request evicted for a lighter source’s |
blind_ask_rate | Blinded requests under new codes refused over a connection’s blind_ask_burst bucket (also counted as pending_targets) |
circuit_cap | Requests refused at a circuit cap |
circuit_evicted | A heavy source’s newest pair torn down for a lighter source’s |
knock_quota | Message request not queued: the asker’s 24-hour quota is spent |
knock_evicted | Queued message request evicted (budget or per-recipient cap) |
queue_overflow | Messages refused because the offline queue was full |
slow_consumer | Connections closed for not draining their queue |
writer_stall | Connections closed because the socket accepted nothing for 30 seconds |
outbound_shed | Connections closed for holding the most of the spent outbound budget |
global_outbound | Sends that found the outbound budget spent |
buffer_shed | Connections closed for holding the most of the WebSocket buffer budget |
task_panic | A connection task panicked; only that connection was closed |
proxy_header | With proxy_protocol = true: missing, malformed or late PROXY v2 header |
proxy_not_loopback | With proxy_protocol = true: connection from a non-loopback peer refused |
unknown_frame | Connections closed for sending a frame with an unknown type (type 0x05 is reserved for future media) |
Blinded pairing (protocol v3) has its own relay-wide counters, with no labels at all: rchat_blind_asks_accepted_total (blinded requests held), rchat_blind_asks_matched_total (matched into a conversation), rchat_blind_asks_busy_total (refused because the token was already used), rchat_blind_asks_expired_total (expired unmatched), rchat_blind_asks_refused_unproven_total (from a connection that had not proved an identity) and rchat_blind_rows_ended_at_publish_total (blinded conversations ended because a member came back on a new connection). rchat_circuits_created_total is not incremented in this release; use rchat_circuits_total.
rchat_tls_cert_not_after_seconds is the expiry of the certificate being served (Unix time). The relay also keeps connection, circuit and relayed-traffic counters (rchat_connections_total, rchat_circuits_total, rchat_relay_bytes_total and others).
Useful alerts:
rchat_tls_cert_not_after_seconds - time() < 7 * 86400: the certificate expires within a week, so renewal is failing.- A rising
rchat_rejections_totalformax_connections,circuit_cap,circuit_evictedorqueue_overflow: a limit is being hit regularly. Check whether it is abuse or real growth. - Any
task_panic: report it.