Skip to content

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).

SessionsWorkersConnect allWorker RSS idleEvent-loop lag p99Accepted / requestedAPI accept rateAccept p50 / p95 / p99Accept→all sentWorker RSS after
10015.1 s297 MB35 ms1,000 / 1,000142 /s338 / 464 / 547 ms13.1 s299 MB
500114.2 s299 MB34 ms2,500 / 2,500153 /s319 / 434 / 572 ms30.7 s340 MB
1,000127.0 s305 MB34 ms5,000 / 5,000141 /s348 / 474 / 544 ms65.3 s408 MB
1,000225.5 s161 + 284 MB34 ms5,000 / 5,000114 /s434 / 531 / 616 ms50.0 s283 + 325 MB

Observations:

  • Connecting is throttled deliberately (RESTORE_CONCURRENCY=25 in 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, no UNKNOWN/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.

ScenarioRequestsThroughputp50p95FailedNotes
100 concurrent clients, 60 s15,450256 req/s343 ms578 ms0%No errors; send accept p95 627 ms
Send burst, 200 clients, 30 s4,259137 sends/s1,439 ms1,762 ms0%All accepted; latency is queueing at the single API process
1,000 concurrent clients, 60 s (k6 in Docker)24,714389 req/s3,589 ms4,654 ms37.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,3881,046 req/s103 ms3,030 ms (p97.5)0 transport errors13,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.

ScenarioRequestsThroughputp50p95FailedNotes
inbox_100: 100 concurrent agents, 60 s13,232190 req/s450 ms687 ms0%List p95 602 ms, timeline p95 642 ms, reply accept p95 851 ms; 0 server errors
inbox_reply_burst: 200 clients, 20 conversations, 30 s2,37474 replies/s2,630 ms3,310 ms0%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)".

MeasurementResult
Segment preview (count + consent breakdown), 70,588 matchingmedian 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 numbers133 messages/s; transaction p50 51 ms, p95 111 ms, p99 182 ms
Duplicate messages per recipient0
Campaign messages claimed while a transactional message waited on the same number0

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).