Skip to content

Performance of the realtime service ​

How many people one Elysion installation carries, measured with the load test in apps/load: simulated people on boards, against the real stack with two realtime replicas behind Traefik, Valkey between them, and the business backend saving the documents. Every number below is from a run you can repeat (pnpm dev:load, then pnpm test:load -- --scenario <name>).

Method ​

  • A client speaks the sync protocol the canvas speaks, moves its own element every 500 ms (what dragging one element does: one Yjs update per move, twice a second) and measures how old the elements of the others are when they arrive. Latency is propagation latency: from the write of the element on one client to its arrival on another, through Traefik, one replica, Valkey and the other replica (the two clients are usually on different replicas). One machine, one clock.
  • A run makes the boards, ramps the clients up, writes for the time of the scenario, stops, waits 8 seconds, then reads every board four times through the edge (so both replicas serve a read) and checks two things: every client's last write is in the document, and the four reads are equal. A run that loses a write or reads different documents fails.
  • What it does not measure: a real browser (rendering, Excalidraw), people who draw many elements (the boards here hold one element per client, so the documents are small: 0.6 to 3.4 KiB; the limits of ADR 0026 are for big boards), the Keycloak login (one login per run), and a network between the clients and the edge (everything is on the loopback).
  • The machine: AMD Ryzen 7 5800H (8 cores, 16 threads), 30 GB, Docker 29.9, the stack of main at the release v0.1.0-beta.158 plus the changes of this branch (compose with docker-compose.load.yml: two realtime replicas, no rate limit at the edge). The load generator ran on the same machine, so the numbers say what the services need, not what a separate client machine would add. CPU figures are docker stats percentages: 100 % is one core.

Results ​

ScenarioClientsBoardsp50p95p99maxRealtime replica (CPU mean / peak, memory peak)Traefik CPUValkey commands/sLost writes
smoke (1 min)2023 ms4 ms6 ms12 ms1 % / 3 %, 99 MiB1 %380
one-board (5 min)5014 ms10 ms15 ms425 ms8 % / 11 %, 111 MiB19 %1000
many-boards (5 min)200202 ms6 ms8 ms504 ms8 % / 13 %, 156 MiB13 %4070
reconnect-storm (3 min)200202 ms5 ms8 ms634 ms10 % / 23 %, 175 MiB14 %4160
replica-kill (3 min)100102 ms4 ms5 ms493 ms6 % / 13 %, 176 MiB (the survivor)7 %2030
stress (2 min)600304 ms8 ms11 ms509 ms29 % / 40 %, 176 MiB69 % / 85 %1,2570

Saves: 14 to 640 per run, 4 to 10 ms each (a whole document to the business backend and Postgres), none failed. Postgres and the business backend stayed under 10 % CPU in every run.

  • The reconnect storm (all 200 clients drop and reconnect within 5 seconds): all reconnected on the first or second try, no connection attempt was refused, nobody lost a write.
  • A replica killed (docker kill, no clean shutdown, started again 30 seconds later): the clients of that replica reconnected to the other one with a new token, the 50 clients of the killed replica were closed with 1006 and reconnected (in two runs, 0 and 6 connection attempts failed before Traefik had taken the dead replica out; everybody was back within seconds), and no client's last write was missing and the four final reads were equal. What the dead replica had not saved yet was still in its clients' documents and came back with them; the updates between replicas travel through Valkey, so the survivor had them as well.
  • The spikes in max (about half a second) are the moments of a save or of a reconnect; the percentiles are what people feel.

The limits that follow ​

These are the numbers for boards of the size the scenarios use, on the machine above. Read them as orders of magnitude.

  • Connections per replica: 300 connections each moving an element twice a second took 29 % of a core and 175 MiB per replica, and the edge (Traefik, which carries every frame of every WebSocket) took more CPU than both replicas together (69 % of a core at 600 connections). The first thing to scale at about 600 simultaneous editors of this kind is the edge (more Traefik replicas), not the realtime service. A person who only looks sends almost nothing, so a replica carries far more viewers than editors.
  • CPU: about 1 millicore per moving connection (290 m for 300). The chart asks 250m and scales at 70 % of it, which is about 180 connections per replica; that is deliberately conservative, a replica did 300 at 40 % of a core at its peak.
  • Memory: an idle replica holds 55 MiB; every board in memory and every connection add to that. 20 boards with 10 connections each took 150 MiB (many-boards), 30 boards with 20 each 175 MiB (stress): about 4 to 5 MiB per board in memory with 10 to 20 connections on it, for boards of a few KiB. A board near the limit of 8 MiB takes about 8 MiB for its document and as much again while it is encoded for a save. The chart asks 256Mi and limits to 512Mi, which holds 30 small boards with 20 people each and a few large ones.
  • Valkey: 1,257 commands per second at 600 clients (about two per client and second: the presence and the update relay), 7 MiB. Valkey is not the limit; it needs a password and not a size (security review, F4).
  • Saves: a save costs 4 to 10 ms for a small document. The save debounce (2 s, at most 10 s) keeps the number of saves at one per board per 2 to 10 seconds however many people draw; a board of 5 MB takes longer per save (the whole state is sent).

What to change when you exceed them ​

You seeChange
The p95 above 100 ms, Traefik near 100 % of a coreMore Traefik replicas (traefik chart: deployment.replicas); give it resources.requests.cpu of 500m or more
Realtime replicas above 70 % CPUMore replicas (replicas.realtime, or the autoscaler: autoscaling.realtime.enabled, with connectionsTarget of about 200 when the Prometheus adapter serves elysion_realtime_websocket_connections)
Replicas killed for memory (OOMKilled)Raise resources.realtime.limits.memory; look at the biggest boards (elysion_realtime_document_size_bytes) and at limits.*
Saves slow or failing (elysion_realtime_document_save_failures_total)The business backend and Postgres: CPU and connections of Postgres, resources.businessBackend, more backend replicas
Boards that are slow to openBoards over 1 MiB: compaction rebuilds them when idle; the limits cap them at 8 MiB and 20,000 elements

Repeat it ​

sh
pnpm dev:load                                         # the stack with two realtime replicas and no rate limit
pnpm test:load -- --scenario smoke                    # one minute; fails above the p95 of its scenario file
pnpm test:load -- --scenario many-boards              # also: one-board, reconnect-storm, replica-kill, stress

The short smoke scenario also runs weekly in CI (.github/workflows/load.yml) on a hosted runner, which is slower than the machine above: its threshold (maxP95Ms in apps/load/scenarios/smoke.json, 500 ms) is far above what it measures here (4 ms), so that it fires for a regression and not for a slow runner. The runs of this page are the reports in apps/load/reports/ of the day they were made.

A pre-release. Released under the MIT license.