How much can one server carry, and what limits you first? This guide explains what a connection and an event cost, gives measured numbers, and works through an 8 GB example. Use it to pick a machine size, then confirm with the load test on your own hardware.

About the numbers. They were measured on one small Linux container (2 vCPU, 8 GB, Erlang/OTP 25, a development build) with the load generator on the same machine. A release build on current Erlang and dedicated cores will do at least as well, but treat every figure as a starting point, not a promise. Repeat the measurement on your setup: it takes a few minutes.

What things cost

A connection

One WebSocket connection is one Erlang process (it holds the socket and runs the protocol) plus a small entry in the channel registry for each channel it joins. Erlang processes are very cheap to create and schedule, which is why this kind of server can hold tens of thousands of them.

The VM allows over a million processes by default (OTP 26 and newer, which the Docker image uses). On older Erlang the default is 262,144; raise it with ERL_FLAGS="+P 1000000".

An idle connection costs memory and a file descriptor, and almost no CPU:

MeasuredResult
Server with no connectionsabout 54 MB
10,000 subscribed connectionsabout 200 MB, so about 15 KB per connection
Same, while delivering 10,000 messages/sabout 200 MB (no growth)

Idle connections "hibernate": Erlang shrinks the process to the minimum while it waits. Before this was added, the same 10,000 connections used about 570 MB (about 52 KB each). Presence channels add a little per member, and busy connections briefly use more while they handle messages, so plan with 20 to 25 KB per connection to be safe.

An event

A trigger (one POST /apps/:id/events) does three things: check the signature, find the channel's subscribers, and send the message to each. The first two are tiny. What costs is delivery: writing the message to each subscriber's socket. So think in messages delivered, not events sent:

messages per second  =  events per second  x  subscribers on the channel

A message to a channel with 500 subscribers is 500 deliveries.

MeasuredResult
Server CPU per delivered messageabout 20 to 35 microseconds

(The cost per message is higher when each event reaches few clients, because the fixed per-event work is spread over fewer messages, and lower for large fan-out.) At 25 microseconds, one CPU core can deliver roughly 40,000 messages per second at 100% use. Plan to run at about half of that, so one core is comfortable up to about 20,000 messages per second.

Bandwidth

Each delivered message is sent on the wire as your payload plus about 70 bytes of protocol envelope, plus TCP/IP headers. A 200-byte payload is about 350 bytes on the wire.

outgoing bandwidth  =  messages per second  x  bytes per message

50,000 messages per second at 350 bytes is about 17.5 MB/s, or 140 Mbit/s. On a modest cloud instance the network can be your limit before the CPU is.

What limits you first

Check these in this order. The smallest answer wins.

LimitFormulaWhere to fix
Open filesconnections + about 1,000 must fit under ulimit -nProduction guide
Memoryconnections x 25 KB, plus the base, with headroom (keep under about 50% of RAM)a bigger machine, or more nodes
CPUmessages/s x 25 us, divided by cores, kept under about 50%more cores, or more nodes
Bandwidthmessages/s x bytes per messagea faster network, or more nodes
Proxy portsabout 28,000 connections per proxy IP to one upstream addressProduction guide

Worked example: an 8 GB, 4-core server

Memory. Use at most half of 8 GB for connections: 4 GB. At 25 KB each, that is about 160,000 connections before memory is the problem. Memory is rarely the limit.

Open files. To allow 100,000 connections, set the limit to at least 101,000 (round up to 131,072).

CPU. Four cores at about 20,000 messages per second each, with half held in reserve, is about 80,000 messages per second.

Bandwidth. 80,000 messages per second at 350 bytes is 28 MB/s, about 225 Mbit/s: fine on a 1 Gbit link, too much for a 100 Mbit one.

So this machine's real limit depends on your traffic, not its RAM. Two shapes:

Traffic shapeConnectionsRateMessages/sResult
Notifications: mostly idle, one small message per user per minute100,0001,700 events/s, 1 recipient each1,700CPU idle. Limit is open files and memory (about 2.5 GB).
Busy chat: 10,000 users in rooms of 100, each room sending 1 message/s10,000100 events/s, 100 recipients each10,000About 0.25 core, about 30 Mbit/s. Very comfortable.
Big live event: 50,000 viewers on one channel, 5 updates/s50,0005 events/s, 50,000 recipients each250,000Over this machine's CPU and bandwidth. Needs more nodes, or fewer updates.

The third row shows the usual trap: fan-out multiplies. One channel with a huge audience costs far more than many small rooms of the same total size.

Choosing a size

  1. Estimate peak connections, and peak messages per second (events x recipients).
  2. Check each limit above. Take the smallest.
  3. Add headroom. Run at half of the CPU figure and well under half of the memory.
  4. For high availability, run at least two nodes, each able to carry the whole load if the other fails, or three nodes each sized for half (Multi-server).
  5. Measure. Use the load test at your real connection count and rate, from a different machine, through your real proxy.

Measure your own server

# connections held, with a realistic message rate
node bench/load.mjs --connections=10000 --rate=50 --duration=60

# server memory: before connecting, and while the test runs
ps -o rss= -p <server pid>

Divide the memory growth by the connection count to get your bytes per connection, and read the server CPU (docker stats, or top) while the test sends to get your cost per message. Your numbers replace the ones in this guide.