Skip to content

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.

All keys go in [server], except the two under [server.offline_queue].

KeyDefaultAllowedLimitsWhen exceeded
max_connections10000≥ 1Simultaneous TCP connections, counted from accept()New connection closed immediately
max_connections_per_ip640 – max_connections (0 = off)Connections from one IPv4 address or one IPv6 /64New connection closed immediately
max_connections_per_ipv6_482560 – max_connections (0 = off)Total connections from one IPv6 /48New connection closed immediately
rate_limit_bps1048576≥ 1024Inbound bytes per second per connectionConnection throttled, not closed
max_message_bytes4194304 (4 MiB)65536 – 67108864Largest WebSocket message or frame from a clientConnection closed
connection_timeout_secs30060 – 86400Time a connection may send nothingConnection closed
identification_timeout_secs151 – 300, and ≤ connection_timeout_secsTime a new connection may go without proving an identity (a hello alone does not count)Connection closed
max_pending_circuit_targets180001 – 1000000Pending 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_percent250 – 90Share of max_pending_circuit_targets kept for named requests (first contact, the daily fallback); blinded requests never hold more than the restBlinded request refused (or the heaviest blinded source’s oldest evicted); a named request under the reserve evicts a blinded one
max_pending_per_connection321 – max_pending_circuit_targetsDistinct targets one connection may have pending within 10 minutes; named requests onlyRequest refused
max_pending_blind_per_connection10241 – max_pending_circuit_targetsBlinded requests one connection may have pending within 10 minutes, counted apart from named ones; about 4 per offline contactRequest refused (RateLimited)
blind_ask_burst641 – 65536Blinded 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_sec81 – 10000Refill rate of that bucket, new codes a second; the app sends at most 32 at once, then 8 a secondRequest refused (RateLimited)
max_pending_per_source10241 – max_pending_circuit_targetsPending named requests from one sourceRequest refused
max_pending_blind_per_source40961 – max_pending_circuit_targetsPending blinded requests from one source, counted apart from named onesRequest refused (RateLimited)
max_circuits_per_connection2561 – 65536Circuits one connection has created, and circuits one identity is a member of across reconnectsRequest refused
max_circuits2000002 – 10000000Circuit routing entries in the whole server (two per pair)Lighter source evicts the heaviest source’s newest pair, or RateLimited
max_circuits_per_sourcemax_circuits / 64 (3125)2 – max_circuitsCircuit sides held by identities that asked from one sourceRequest refused
offline_queue_max_total_bytes268435456 (256 MiB)≥ per-recipient capBytes in the offline queue across all recipientsMessage refused, not acknowledged
offline_queue_max_bytes_per_recipient16777216 (16 MiB)≥ max_message_bytesOffline bytes queued for one recipientMessage refused, not acknowledged
tls_handshake_timeout_secs101 – 120Time to finish the TLS handshakeConnection dropped
outbound_budget_bytes1073741824 (1 GiB)16 MiB – 64 GiBBytes queued for sending across all connectionsHeaviest connection of the heaviest source closed
buffer_budget_bytes536870912 (512 MiB)max_message_bytes – 64 GiBWebSocket buffer bytes across all connectionsHeaviest connection of the heaviest source closed
[server.offline_queue] ttl_secs259200 (72 h)3600 – 259200Lifetime of a queued message (72 h is the maximum)Message deleted
[server.offline_queue] knock_budget_bytes16777216 (16 MiB)Bytes of queued message requests, separate from messagesOldest 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.

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").

A source is an IPv4 address or an IPv6 /48. Everyone behind one source shares one allowance:

  • max_pending_per_source pending named conversation requests and, separately, max_pending_blind_per_source blinded 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_source circuit 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 get CircuitClosed and 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.

Each connection has a token bucket over the bytes it sends:

  • It refills at rate_limit_bps bytes 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.

  • Idle: a connection that sends nothing for connection_timeout_secs is 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.

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.

These cannot be configured.

LimitValue
Outbound queue per connection256 messages and 16 MiB
Wait for space in a recipient’s queue5 seconds, then the recipient is disconnected as stuck
Writer stall30 seconds with nothing written while a message waits, then the connection is closed
Offline messages per recipient1000
Steady-size (1,226-byte) offline messages per recipient500 (half the queue)
Messages tracked for delivery100,000

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

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.

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.

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.

With [metrics] enabled = true (the default), the relay serves Prometheus metrics on 127.0.0.1:9090:

Terminal window
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=...}:

ReasonMeaning
max_connectionsConnections refused at the global cap
per_ip_capConnections refused at the per-address cap
per_prefix_capConnections refused at the IPv6 /48 cap
rate_limitRead pauses for exceeding rate_limit_bps
oversized_frameConnections closed for exceeding max_message_bytes
handshake_timeoutConnections closed for not finishing the WebSocket upgrade
idle_timeoutConnections closed for idleness
identification_timeoutConnections closed for not proving an identity in time
auth_rateConnections closed for exceeding the hello and publish budget
key_authPublishes refused: bad bundle signature, or a connection proof that does not match this TLS session
pending_targetsRequests refused at the pending caps
pending_evictedA heavy source’s pending request evicted for a lighter source’s
blind_ask_rateBlinded requests under new codes refused over a connection’s blind_ask_burst bucket (also counted as pending_targets)
circuit_capRequests refused at a circuit cap
circuit_evictedA heavy source’s newest pair torn down for a lighter source’s
knock_quotaMessage request not queued: the asker’s 24-hour quota is spent
knock_evictedQueued message request evicted (budget or per-recipient cap)
queue_overflowMessages refused because the offline queue was full
slow_consumerConnections closed for not draining their queue
writer_stallConnections closed because the socket accepted nothing for 30 seconds
outbound_shedConnections closed for holding the most of the spent outbound budget
global_outboundSends that found the outbound budget spent
buffer_shedConnections closed for holding the most of the WebSocket buffer budget
task_panicA connection task panicked; only that connection was closed
proxy_headerWith proxy_protocol = true: missing, malformed or late PROXY v2 header
proxy_not_loopbackWith proxy_protocol = true: connection from a non-loopback peer refused
unknown_frameConnections 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_total for max_connections, circuit_cap, circuit_evicted or queue_overflow: a limit is being hit regularly. Check whether it is abuse or real growth.
  • Any task_panic: report it.