Benchmarks
View SourceSingle-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.srcdefault 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.
| Connections | Messages | Throughput | RTT p50 | RTT p99 | Memory/conn |
|---|---|---|---|---|---|
| 100 | 10,000 | 35,000 msg/sec | 1.4ms | 5.1ms | ~20KB |
| 3,500 | 7,000,000 | 83,000 msg/sec | 4.4ms | 6.5ms | ~15KB |
| 7,000 | 695,800 | 39,000 msg/sec | 5.8ms | 19.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.
| Connections | Messages each | Total delivered | Throughput |
|---|---|---|---|
| 3,500 | 2,000 | 7,044,000 | 83,000 msg/sec |
All messages delivered, none lost.
HTTP REST API
100 concurrent players, each running register, login, then API reads.
| Endpoint | p50 | p95 | p99 |
|---|---|---|---|
POST /api/v1/auth/register | 1,463ms | 1,464ms | 1,464ms |
POST /api/v1/auth/login | 724ms | 1,278ms | 1,308ms |
GET /api/v1/matches | 8ms | 45ms | 64ms |
GET /api/v1/friends | 7ms | 99ms | 133ms |
GET /api/v1/wallets | 11ms | 272ms | 280ms |
GET /api/v1/players/:id | 14ms | 191ms | 194ms |
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 isN xlooser 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:
| Config | pool_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
| Variable | Default | Meaning |
|---|---|---|
ASOBI_LOAD_N | 100 | HTTP benchmark: concurrent players |
ASOBI_BENCH_PLAYERS | 1000 | WS benchmark: players to register |
ASOBI_BENCH_BATCH | 50 | WS benchmark: registration batch size |
ASOBI_WS_N | 500 | WS benchmark: concurrent connections |
ASOBI_WS_MSGS | 200 | WS benchmark: messages per connection |
ASOBI_WS_WAVE | 200 | WS benchmark: connections per wave |
Both suites need a running PostgreSQL 17 - see Self-hosting.