Benchmarks

View Source

Single-node performance measurements. Client and server run on the same machine, so a real deployment with the load generator elsewhere will see higher server throughput than the tables below.

Measured on 2026-04-02 at commit 8069c02. Re-run them yourself before you size anything: the numbers below are one machine on one day, not a promise.

Test environment

  • 8 cores, client and server sharing them
  • Erlang/OTP 28 and PostgreSQL 17, what the image was built on at that commit. asobi builds on OTP 29 today - see Self-hosting
  • PostgreSQL in Docker with max_connections=500, shared_buffers=256MB
  • Database pool: 200 connections (the dev_sys.config.src default the CT profile runs with)
  • One Erlang node, no clustering

WebSocket throughput

Heartbeat round-trip: the client sends session.heartbeat, the server replies with a timestamp. This measures the whole WebSocket pipeline including JSON encode and decode.

ConnectionsMessagesThroughputRTT p50RTT p99Memory/conn
10010,00035,000 msg/sec1.4ms5.1ms~20KB
3,5007,000,00083,000 msg/sec4.4ms6.5ms~15KB
7,000695,80039,000 msg/sec5.8ms19.9ms~13KB

Peak sustained: ~83,000 messages/sec at 3,500 concurrent connections.

At 7,000 connections per-message throughput drops because the benchmark client is competing with the server for CPU on the same machine.

Blast mode

Fire-and-forget: all messages sent before waiting for any reply. Measures raw server processing capacity.

ConnectionsMessages eachTotal deliveredThroughput
3,5002,0007,044,00083,000 msg/sec

All messages delivered, none lost.

HTTP REST API

100 concurrent players, each running register, login, then API reads.

Endpointp50p95p99
POST /api/v1/auth/register1,463ms1,464ms1,464ms
POST /api/v1/auth/login724ms1,278ms1,308ms
GET /api/v1/matches8ms45ms64ms
GET /api/v1/friends7ms99ms133ms
GET /api/v1/wallets11ms272ms280ms
GET /api/v1/players/:id14ms191ms194ms

Register and login are slow on purpose: pbkdf2 at 100,000 iterations is meant to cost CPU. Everything else is sub-15ms at p50.

Game type suitability

Mobile and casual (turn-based, party, puzzle)

Good fit. Sub-10ms WebSocket RTT, thousands of concurrent connections per node. Most mobile games send well under 100 messages/sec per player.

Persistent worlds

Viable per world. 3,000-7,000 concurrent connections per node with acceptable latency.

One world lives entirely on one node and does not migrate, so a node is not a slice of a shared world - it is a set of separate worlds. Reaching 20,000 CCU across 5-10 nodes therefore means running 5-10 sets of worlds, with players sharded across them by your own routing. If your design needs one world larger than a single node can hold, adding nodes does not help. See Clustering.

Competitive real-time (FPS, fighting, racing)

Not the target. WebSocket over TCP has a 5-25ms RTT floor and these genres want UDP under 3ms. Run a UDP transport for game state alongside asobi and use asobi for everything else: auth, matchmaking, economy, social, leaderboards.

Bottlenecks and tuning

Authentication under load

pbkdf2 saturates CPU during login storms. Mitigations:

  • Rate-limit /api/v1/auth/* at the reverse proxy. asobi's own limiter is per node, so it is N x looser across a cluster - see Clustering.
  • More nodes behind a load balancer, to spread the pbkdf2 work.

Database pool

The pool is pool_size under the kura application in sys.config. What ships:

Configpool_size
config/prod_sys.config.src (the image)20
config/dev_sys.config.src (dev and CT)200

The production default of 20 is deliberately conservative, because every node opens its own pool and PostgreSQL's max_connections is a fleet-wide budget: nodes x pool_size has to fit inside it with room for your own tooling. Raise it when you see queueing on database-bound endpoints, and raise max_connections to match.

Memory

WebSocket connections cost ~13-20KB each, so at the concurrency measured above memory is not the constraint. CPU spent on message processing is.

Running the benchmarks

# HTTP load test (default 100 players)
ASOBI_LOAD_N=500 rebar3 ct --suite=asobi_load_bench

# WebSocket benchmark. Phase 1 registers players (cached after the first run),
# phase 2 connects and blasts heartbeats.
ASOBI_BENCH_PLAYERS=5000 \
ASOBI_WS_N=5000 \
ASOBI_WS_MSGS=2000 \
ASOBI_WS_WAVE=200 \
rebar3 ct --suite=asobi_ws_bench
VariableDefaultMeaning
ASOBI_LOAD_N100HTTP benchmark: concurrent players
ASOBI_BENCH_PLAYERS1000WS benchmark: players to register
ASOBI_BENCH_BATCH50WS benchmark: registration batch size
ASOBI_WS_N500WS benchmark: concurrent connections
ASOBI_WS_MSGS200WS benchmark: messages per connection
ASOBI_WS_WAVE200WS benchmark: connections per wave

Both suites need a running PostgreSQL 17 - see Self-hosting.