Architecture and benchmarks

How to read the numbers: this document has two throughput figures of different kinds. Do not multiply or divide one by the other.

  • Per-node production-like capacity: about 6,000 frames/s. Measured on an AWS EC2 t3.medium with the node limited to 0.2 vCPU (roughly 30,000 per node vCPU). This is what one node delivers in a production-like setup; it is "Result 1" below.
  • Dev-machine synthetic load: about 150,000 frames/s. Measured on one fast dev machine with many cores (Apple M5 Pro, 18 cores), running the server, PostgreSQL, Redis and the load generator together. It is synthetic load from a single box and not a production number; it is "A separate measurement" below.

The two differ by more than 20x because of the CPU a node may use. The first gave the node only 0.2 vCPU; in the second, the server and the load generator shared 18 cores. The same engine gives very different numbers depending on how much CPU it gets.

Add a node and the same capacity is added, one for one. A node limited to 0.2 vCPU delivers about 6,000 frames per second (roughly 30,000 per node vCPU), and with 1, 2 and 3 nodes we measured exactly 1x, 2x and 3x. Publishing (storing a message) goes through one PostgreSQL, and that PostgreSQL takes about 1,300 publishes per second per core (the one-core figure is extrapolated). Below: the structure, two result tables, one table of dev-machine synthetic load, how we measured, and everything we did not measure.

These numbers are not a capacity plan. Results 1 and 2 used deliberately small nodes to see how capacity grows, so do not size your service from the absolute values. Read "Limits" first.

Structure

   Browsers / apps (WebSocket)
        |         |         |
   +----v---+ +---v----+ +--v-----+
   | Node 1 | | Node 2 | | Node N |     connections are spread over nodes
   +----+---+ +---+----+ +--+-----+
        |  ^      |  ^      |  ^
 publish|  |      |  |      |  |  every message is delivered
        v  |      v  |      v  |  through Redis, even to the same node
   +--------+------+--+------+--+---+
   |         Redis pub/sub          |
   +--------------------------------+
        ^
        | one write per publish
   +----+--------------+
   |    PostgreSQL     |   per-room sequence number + message row
   +-------------------+
  • Nodes share the connections. A browser holds a WebSocket to one node. Adding a node lets that many more connections in.
  • Delivery goes through Redis pub/sub. When a message is published, every node receives it and writes it to its own sockets. The node that took the publish receives it through Redis too, so a receiver on the same node and a receiver on another node take the same path.
  • A publish writes to PostgreSQL once. It issues the room's sequence number (seq) and stores the message. One publish costs PostgreSQL one room lookup and one transaction (bump the number + insert the message).

You can add nodes, but there is one PostgreSQL. So two things grow and saturate separately: delivery (nodes and Redis) and publishing (PostgreSQL). The two tables below are those two.

Result 1: delivery scales with the number of nodes

One room, 300 connections, 30 publishers. One message becomes 299 frames, so 20 publishes/s is about 6,000 frames/s. "Sustained" means the achieved publish rate equals the offered rate, delivery is 100%, and no connection was dropped as a slow consumer; the table shows the highest such level.

Nodes Limit per node Max sustained publishes/s Frames delivered/s vs. 1 node CPU per frame
1 0.2 vCPU, 96 MB 20 about 5,900 1.0x 33 µs
2 0.2 vCPU, 96 MB 40 about 12,000 2.0x 32 µs
3 0.2 vCPU, 96 MB 60 about 18,000 3.0x 32 µs

Each cell is the rounded mean of 2 runs; throughput of the 2 runs agreed within 4%.

  • CPU per frame is 32-33 µs regardless of node count. The per-node extras (receiving from Redis, decoding each message once) are invisible next to the fan-out. 1 / 33 µs, about 30,000 frames/s per node vCPU, comes from here.
  • Delivery between nodes is close to free. The p99 latency of receivers on the publishing node and receivers on other nodes differed by a few ms (3 nodes at 45 publishes/s: 109 ms vs. 105 ms).
  • One level up and it falls over. Once each node hits its CPU limit, latency jumps to seconds (for example, 1 node at 25/s: p99 4.8 s). The bottleneck was the node CPU every time; the load generator, Redis and PostgreSQL stayed below their limits.
  • Headroom is fast. At 75% of the limit (45/s on 3 nodes): p50 20 ms, p99 107 ms. These latencies are inflated for the reason under "Limits", so only compare them across node counts.

Result 2: publishing is capped by PostgreSQL cores

50-1,000 rooms, 2 connections per room (1 publisher, 1 receiver): many rooms, many publishes. A publish is one frame, so there is almost no fan-out and the work is the publish path itself.

Nodes PostgreSQL limit Max sustained publishes/s What saturated first
1 0.3 core about 175 node CPU
2 0.3 core about 345 node CPU (almost together with PostgreSQL)
3 0.15 core about 180 PostgreSQL
3 0.3 core about 390 PostgreSQL
3 0.6 core about 500 node CPU
3, app with webhooks on 0.3 core about 270 PostgreSQL
  • About 1,300 publishes/s per PostgreSQL core. One publish costs 0.7-0.8 ms of PostgreSQL CPU. 0.15 core saturated at 180 publishes/s (about 1,200 per core) and 0.3 core at about 390 (about 1,300 per core). We never filled a whole core. On this server three nodes (0.6 vCPU) generate only 0.35 core of PostgreSQL work. The one-core figure is an extrapolation from those two points and from the per-publish CPU at 0.6 core, and we only verified linearity up to a 2x limit giving 2.1x throughput.
  • The engine is not wasting PostgreSQL. Running the same three statements (room lookup, number bump, message insert) with pgbench and no engine, on the same 0.3 core, tops out at about 405 transactions per second; the engine path (about 390) reaches 96% of that. The bottleneck is the amount of work PostgreSQL has to do, not engine overhead.
  • Room count is not the knee. 50 or 1,000 rooms, 2 or 10 connections per room: the ceiling was the same (366-400 publishes/s). Lock waits were zero except in the pool-16 experiment. It is not the connection pool either: raising it (4 to 16 per node) made things 10-15% worse, because backends pile up on 0.3 core and wait on each other.
  • More nodes do not lift the PostgreSQL ceiling. From 3 nodes on, adding nodes does not go above about 390, and giving PostgreSQL more cores moves the ceiling back to the nodes (about 500). For publish-heavy services, PostgreSQL, not the node count, sets capacity.
  • An app with webhooks on costs about 40% more PostgreSQL per publish (each publish writes one more delivery record; this row is a single run). And one app's webhook delivery saturates at 25-60 per second: publish faster than that and deliveries fall behind.
  • Node memory is about 100-125 KB per connection. With a 256 MB limit, opening 2,000 connections killed the node for lack of memory.

A separate measurement: synthetic load on one dev machine

This is not a production setup. It asks how far one node goes when it can use plenty of CPU. In September 2026 we ran the server, PostgreSQL, Redis and the load generator together on one dev machine (Apple M5 Pro, 18 cores). 1 node, 1 room, 30-second runs, up to 400 connections in the room. The load generator saturated before the server did, so every value is a lower bound.

Connections Messages sent/s Frames delivered/s Client p99 Dropped connections
20 25 472 5 ms 0
50 100 4,883 18 ms 0
100 200 19,730 25 ms 0
400 100 39,614 55 ms 0
400 400 157,873 52 ms 0
400 800 (limit point) 162,413 4.8 s 84 / 400
  • The "about 150,000 frames/s" is the fifth row. 400 messages/s x 400 people in the room is about 160,000 frames/s delivered; 99% of messages reached everyone within 52 ms, and no connection was dropped under that load.
  • It falls over at 800 messages/s. Latency jumps to seconds and 84 slow connections were cut. Above this is the limit of one node under these conditions.
  • Frames/s = room members x messages sent/s. The table is the extreme of 400 people in one room. 800 rooms of 20 people posting once a second is 16,000 frames/s, about the third row.
  • These are not production-instance figures. One node, no horizontal scaling, and the server, load generator and database shared the cores of one machine. To plan capacity, use "Result 1" and "Result 2" above.

How we measured

This is how Results 1 and 2 were measured. The conditions of the dev-machine test are under "A separate measurement".

  • Hardware: one AWS EC2 t3.medium (2 vCPU), with other services running on it. The test's share was about 1-1.1 vCPU with 3 nodes; whole-server CPU was 57-86%.
  • We imitated "N small machines". Starting several node processes on one server only shows that "it runs on several" because they share the server's CPU. So every node got the same fixed CPU and memory limit (cgroup: CPUQuota=20%, MemoryMax=96M), giving each node an equal share. If the work really is split across nodes, throughput must grow in proportion to the node count.
  • PostgreSQL 17 and Redis 7 were started separately for the test (docker, PostgreSQL --cpus=0.3 --memory=192m, Redis --cpus=0.15). Nothing connected to any production database. We connected by container address without opening host ports (opening them let a proxy copy the traffic once more and inflated server CPU).
  • Latency is measured on the receiving side's clock: the time until a different client receives the frame, not the one that published. The load generator is a Go WebSocket client; at the heaviest load (18,000 frames/s) it used 0.33 core and 28 MB.
  • 25 seconds per run: 5 s warm-up + 20 s measurement. Most cells were measured twice, and cells with a single run say so.
  • We recorded the server side too: node CPU, PostgreSQL CPU and throttling, connection states and wait events, Redis CPU. That is what lets us say what saturated.
  • Re-running with the same room and user makes the server skip the write and only return an ack, because of duplicate protection. So every run uses fresh identifiers.

Limits: what these numbers do not say

  • Latency is inflated. A cgroup CPU limit gives the right throughput (CPU time) but stretches latency. A 20% limit lets a node run only 2 ms out of every 10 ms, so any job over 2 ms (one 300-socket fan-out) takes up to 5x longer on the wall clock. A real small server would do better. Trust latency only when comparing node counts. Right at the limit, latency varied a lot between runs (the same setting gave a p50 of 167 ms in one run and 908 ms in another).
  • There is one PostgreSQL, so publish-heavy load saturates PostgreSQL first. More nodes do not fix it. The roughly 1,300 per PostgreSQL core above comes from this test's PostgreSQL configuration, and anything above one core is extrapolated.
  • We did not measure many publishes into one room. The test spread the load over rooms. A room's sequence number is issued inside a row lock on that room, so the ceiling for one room is set separately by transaction time. One popular live stream with a flood of chat is this case.
  • We did not measure Redis's own limit. Each message adds one pub/sub delivery per node. Redis used 0.05 core at 3 nodes and 70 publishes/s, so that was far from the limit, but dozens of nodes at thousands of messages per second need a separate test.
  • The test PostgreSQL differs from production. 32 MB shared buffers, a container disk, a statistics extension on. A production database is larger and shares CPU with other databases on its server.
  • No real network between machines. The test ran over loopback and a docker bridge. In the same availability zone each hop adds 0.1-1 ms plus network card and transfer cost.
  • The server also ran other services. The response times of the other services on the server did not change during the test, but these are not numbers from a clean dedicated server.
  • Also not looked at: very large connection counts, reconnect storms, lots of accumulated data (the test's messages table reached at most a few hundred thousand rows), runs longer than 20 seconds, and load mixed with reads and presence updates.

The per-app default limits (Limits) are a safety measure so one app's runaway does not slow another, and are separate from the capacity numbers above.

Reproducing

The test code and scripts live in the engine repository, which is private, so we cannot link to it. Here is the method so you can set up the same test yourself.

  1. Start N nodes on one server and give each the same limit. With systemd: systemd-run -p CPUQuota=20% -p MemoryMax=96M ….
  2. Start a throwaway PostgreSQL and Redis. docker run --cpus=0.3 --memory=192m …, Redis with --cpus=0.15. Do not open host ports; connect by container address. Do not connect to a production database.
  3. Create an app and raise its publish-rate and connection limits above the test values. At default limits, the limits stop you first.
  4. Use a WebSocket load generator and spread connections round-robin over the nodes. Delivery test: one room, 300 connections, 30 publishers. Publish test: N rooms, 2 connections per room (on different nodes). Use fresh identifiers every run and measure latency as the time a different client receives the frame. With hundreds of connections and tens of thousands of frames per second, a JS load generator saturates before the server does, so prefer something like Go.
  5. Warm up 5 s + measure 20 s, and raise the level until "offered rate = achieved rate, 100% delivery, 0 slow consumers" breaks. Record node CPU, PostgreSQL CPU and throttling (docker stats or cgroup stats) and pg_stat_activity every second to see what saturated.
  6. If the server does other work, check CPU credits and free memory first, and stop when server CPU passes 85%.