Scaling
Starting point
1 web · 1 api · 1 whatsapp-worker · 1 webhook-worker · 1 scheduler · PostgreSQL · Redis
This is the shape of infrastructure/compose/docker-compose.prod.yml and is sufficient for hundreds of instances (see
Benchmarks for measured orchestration overhead).
Scaling each component
| Component | How | Notes |
|---|---|---|
| web, api | Add replicas behind Traefik | Stateless; caches (entitlements 30 s, API keys 10 s) are per replica and invalidated on revocation |
| whatsapp-worker | Add replicas with unique WORKER_ID (default: hostname-pid) | Each claims up to WORKER_MAX_SESSIONS; leases guarantee a single owner per instance |
| webhook-worker | Add replicas | Deliveries are leased with SKIP LOCKED + locked_until |
| scheduler | 1–2 replicas | BullMQ job schedulers run each job once per interval |
| PostgreSQL | Vertical first; connection pooling (PgBouncer in transaction mode is compatible) | Size DATABASE_POOL_MAX × replicas below max_connections |
| Redis | Single primary with AOF | Low memory footprint (buckets, short-lived keys, BullMQ commands) |
Session ownership across workers
┌────────── PostgreSQL (whatsapp_instances.lease_*) ──────────┐
│ wa_1 owner=w1 epoch=7 wa_2 owner=w2 epoch=3 wa_3 (none) │
└──────▲────────────────────────▲────────────────────▲──────┘
heartbeat │ heartbeat │ claim loop │
┌──────┴─────┐ ┌──────┴─────┐ ┌──────┴─────┐
│ worker w1 │ │ worker w2 │ │ worker w3 │
└────────────┘ └────────────┘ └────────────┘
API commands → BullMQ queue "wa-control-<owner>" (or "wa-unassigned" if no live owner)
- One active owner: the claim
UPDATEonly succeeds on an unowned or expired lease and increments the fencing epoch. - No split brain: a Redis lock is not used for ownership. Even if two workers believe they own an instance (e.g.
after a network partition), only the one with the current epoch can write session keys or claim messages; the other
gets
LeaseLostErroron its first write and on its next heartbeat, and closes its socket. Workers that cannot renew for a full TTL close all sockets proactively. - Command routing: the API sends commands to the owner's queue; unowned instances go to a shared queue where any worker with capacity claims them. Commands are nudges; the database intent is authoritative.
- Staged startup: claims happen in batches of
RESTORE_CONCURRENCYwith jitter. - Rebalancing: not automatic in v1. New workers pick up unowned and expired instances; to drain a worker, stop it gracefully and the others take over its instances.
Fairness
- Each instance has its own send lane, so per-account pacing and backlogs are isolated.
- Token buckets at instance, project, organization and global level are taken atomically.
- Projects have a backlog cap (
maxQueuedMessagesPerProject) and a monthly quota. - API requests are rate limited per project and globally.
Capacity planning
A real Baileys session costs memory (Signal sessions, caches), CPU for encryption, and one WebSocket. The simulated
numbers in Benchmarks exclude these costs. Measure with real, authorized accounts
(see Manual validation) and set WORKER_MAX_SESSIONS with headroom; watch
nodejs_eventloop_lag_p99_seconds, process_resident_memory_bytes and whatstig_worker_sessions_active.
Deliberately not built (yet)
Kubernetes operators, automatic session rebalancing, sharded PostgreSQL, Kafka. The lease design does not preclude them, but none is needed at the current scale.