Benchmarks
All numbers on this page are SIMULATED. WhatsApp sessions were provided by the mock provider, which performs no network I/O and no Signal encryption. They measure WhatsTIG's own orchestration (leases, send lanes, database queue, API acceptance), not how many real WhatsApp connections a worker can hold. Real-session capacity has not been measured yet; see Manual validation.
Raw results: tests/load/results/*.json. Reproduce with the commands below.
Environment
Single developer workstation: Windows 11, 14 logical CPUs, Node.js 24.19, all services as single processes on the host;
PostgreSQL 17, Redis 8 and SeaweedFS in Docker Desktop. One API process (DATABASE_POOL_MAX=20). Production hardware
and Linux networking will differ.
Session orchestration (mock sessions)
pnpm build
pnpm --filter @whatstig/tests exec tsx load/session-sim.ts --sessions 500 --workers 1 --messages 2500
N instances are seeded with desired_state = CONNECTED; workers claim, pair (mock auto-pair 1 s + restart) and connect
them; then messages are sent through the public API (50 concurrent clients) and drained through the send lanes with
receipts. Rate limits were lifted for this run (we measure orchestration, not policy).
| Sessions | Workers | Connect all | Worker RSS idle | Event-loop lag p99 | Accepted / requested | API accept rate | Accept p50 / p95 / p99 | Accept→all sent | Worker RSS after |
|---|---|---|---|---|---|---|---|---|---|
| 100 | 1 | 5.1 s | 297 MB | 35 ms | 1,000 / 1,000 | 142 /s | 338 / 464 / 547 ms | 13.1 s | 299 MB |
| 500 | 1 | 14.2 s | 299 MB | 34 ms | 2,500 / 2,500 | 153 /s | 319 / 434 / 572 ms | 30.7 s | 340 MB |
| 1,000 | 1 | 27.0 s | 305 MB | 34 ms | 5,000 / 5,000 | 141 /s | 348 / 474 / 544 ms | 65.3 s | 408 MB |
| 1,000 | 2 | 25.5 s | 161 + 284 MB | 34 ms | 5,000 / 5,000 | 114 /s | 434 / 531 / 616 ms | 50.0 s | 283 + 325 MB |
Observations:
- Connecting is throttled deliberately (
RESTORE_CONCURRENCY=25in the simulation, 5 by default) with jitter; 1,000 sessions come up in under 30 s with one worker. - End-to-end throughput (~77–100 messages/s) is bounded by API acceptance on a single API process; send lanes kept up
(every run drained with all messages
SENT/DELIVERED/READ, noUNKNOWN/FAILED). - Orchestration overhead per mock session is small (~0.1 MB); real Baileys sessions will cost substantially more.
- An earlier run exposed a connection-pool starvation bug in message acceptance (reads outside the transaction connection under concurrency: 3 msg/s, 71% rejected). It was fixed before these measurements.
API load (k6)
pnpm --filter @whatstig/tests exec tsx load/session-sim.ts --sessions 50 --workers 1 --serve 1 # keeps a seeded stack up
docker run --rm -i --add-host=host.docker.internal:host-gateway --env-file tests/load/results/.k6.env \
-e SCENARIO=clients_100 -v "$PWD/tests/load:/load" grafana/k6:1.3.0 run /load/k6/api.js
Mixed workload: 40% message listing, 30% instance status, 30% sends (unique idempotency keys) across 50 instances.
| Scenario | Requests | Throughput | p50 | p95 | Failed | Notes |
|---|---|---|---|---|---|---|
| 100 concurrent clients, 60 s | 15,450 | 256 req/s | 343 ms | 578 ms | 0% | No errors; send accept p95 627 ms |
| Send burst, 200 clients, 30 s | 4,259 | 137 sends/s | 1,439 ms | 1,762 ms | 0% | All accepted; latency is queueing at the single API process |
| 1,000 concurrent clients, 60 s (k6 in Docker) | 24,714 | 389 req/s | 3,589 ms | 4,654 ms | 37.9% | All failures were connection refused from Docker Desktop's host port forwarding, 0 server errors |
| 1,000 concurrent connections, 30 s (autocannon, native) | 31,388 | 1,046 req/s | 103 ms | 3,030 ms (p97.5) | 0 transport errors | 13,906 × 200, 17,482 × 429 RATE_LIMITED from the project's plan limit (20,000/min) — limiter working as designed |
Conclusions for capacity planning:
- One API process accepts ~140–150 messages/s and serves ~250–1,000 read requests/s on this machine. Scale the API horizontally (stateless) for more.
- Under 1,000 concurrent clients the API stayed up and enforced per-project rate limits; the k6-in-Docker failures were a test-harness networking limit, confirmed by the native run.
Team inbox API (k6)
pnpm --filter @whatstig/tests exec tsx load/session-sim.ts --sessions 50 --workers 1 --inbox --serve 1 # inbox enabled, key with conversation scopes
docker run --rm -i --add-host=host.docker.internal:host-gateway --env-file tests/load/results/.k6.env \
-e SCENARIO=inbox_100 -v "$PWD/tests/load:/load" grafana/k6:1.3.0 run /load/k6/inbox.js
setup() starts 200 conversations across the 50 mock instances with #reply, then waits until the mock customers'
replies are threaded. Agent workload: 50% list views (open queue, unassigned, longest waiting, search), 35%
timelines, 15% replies (unique idempotency keys). The burst scenario has 200 clients replying into 20 hot
conversations, so every reply competes for the same conversation and contact row locks.
| Scenario | Requests | Throughput | p50 | p95 | Failed | Notes |
|---|---|---|---|---|---|---|
inbox_100: 100 concurrent agents, 60 s | 13,232 | 190 req/s | 450 ms | 687 ms | 0% | List p95 602 ms, timeline p95 642 ms, reply accept p95 851 ms; 0 server errors |
inbox_reply_burst: 200 clients, 20 conversations, 30 s | 2,374 | 74 replies/s | 2,630 ms | 3,310 ms | 0% | All accepted. About half of send_burst's 137/s because replies to one conversation are serialized by its row lock |
Raw results: tests/load/results/k6-inbox_*.json. Message content retention is off in the load project, so timelines
carry no message text.
Campaign engine (in-process, no WhatsApp I/O)
pnpm --filter @whatstig/tests exec tsx load/campaign-sim.ts --contacts 100000 --instances 200 --release 20000 --loops 8
The simulator seeds 100,000 contacts in one project (attributes, a tag, 80% opted in, 10% opted out) and 200 connected
instances. It then runs the campaign worker's own starter, materializer and release functions in one process. A
stand-in for the send lane claims messages the way the lane does (priority, then age, per instance) and marks them
sent at once. The audience is the segment "city in (3 cities) and (tagged or orders ≥ 2)".
| Measurement | Result |
|---|---|
| Segment preview (count + consent breakdown), 70,588 matching | median 101 ms, max 126 ms |
| Audience snapshot, 70,588 recipients (23,529 skipped for consent) | 14.4 s (4,900 rows/s), chunks of 2,000 |
| Release, 8 loops in one replica, 20,000 messages over 200 numbers | 133 messages/s; transaction p50 51 ms, p95 111 ms, p99 182 ms |
| Duplicate messages per recipient | 0 |
| Campaign messages claimed while a transactional message waited on the same number | 0 |
At the default sending limit of 20 messages per minute per number, 200 numbers can send at most about 66 messages per
second. One campaign-worker replica with the default 4 release loops releases faster than that; the release rate is
not what limits a campaign, the numbers' limits are.
Releases on different numbers of the same campaign run in parallel: a release takes only a key-share lock on the campaign row. An earlier version locked the row exclusively and was limited to about 26 releases per second per campaign, whatever the number of numbers.
Raw result: tests/load/results/campaigns-100000-instances-200.json.
Not yet measured
- Real Baileys sessions per worker (memory, CPU, event-loop lag) — requires authorized test accounts.
- Recovery timings for PostgreSQL and Redis restarts under load (the behaviour is covered by integration/e2e tests and the reliability design; timings are not benchmarked).
- Long-running soak tests (24 h+).
- Webhook delivery throughput against slow or failing endpoints at scale.
- Campaigns end to end with real numbers (delivery and read rates, WhatsApp-side throttling).