Skip to main content
Version: v0.23.1

Horizontal scaling

This page explains why OpenWA runs as a single instance, which parts of it you can scale out, and how to grow capacity safely within that model. Read it before you reach for replicas: 3.

One instance per session volume, unless you opt into multi-node

OpenWA ships as a single-process application, and the safe default is still exactly one API instance per session-data volume (replicas: 1). Two instances sharing the same session storage corrupt WhatsApp authentication and can get the linked account logged out or banned. Sticky sessions and shared storage do not fix this on their own.

Multi-node operation is now possible opt-in as of v0.14.0, but only when every node opts into the session-ownership layer (NODE_URL on every node, a stable NODE_ID, Redis for the event fan-out, and synchronized clocks). Without that layer, the corruption risk below is unchanged. The prerequisites and the gaps that remain are explained in Multi-node operation (experimental).

The core constraint: engine state lives in memory

Every running session is a live, stateful connection to WhatsApp:

  • a Chromium browser, on the default whatsapp-web.js engine, or
  • a WebSocket client, on the baileys engine (set ENGINE_TYPE=baileys).

OpenWA holds that connection — plus its reconnect, error, and status state — in in-memory maps inside the API process. By default there is no shared registry of which instance owns which session, so two processes can race to start the same session; the opt-in claim and lease added in v0.14.0 (Multi-node operation (experimental)) closes that race only when you enable it. When the process stops, the live connections stop with it; another process cannot pick them up.

That single fact drives the entire scaling model. A second instance has no way to know a session is already live elsewhere, so it tries to bring that session online too.

Two instances writing the same on-disk auth directory for one session corrupts it. WhatsApp's linked-device auth is not designed for concurrent writers, so you get a forced logout, a re-scan loop, or — worst case — a banned account. The danger peaks with AUTO_START_SESSIONS=true, where every instance tries to bring every session online the moment it boots.

Sticky sessions are not a workaround

Cookie or IP affinity at the load balancer reduces the windows where two instances touch the same session, but it does not close them — failover, a rolling deploy, or a brief overlap still produces two writers. Affinity is not a substitute for an enabled claim/lease. Without the opt-in ownership layer, keep one instance.

What you can share, and what you can't

OpenWA already supports shared external datastores. That is what lets one instance grow, and it is the foundation a future multi-instance design would build on. What it does not have is a way to share live engine state.

StateWhere it livesShareable today?
Persistent data (session metadata, messages, webhooks, contacts)SQLite, or PostgreSQL with DATABASE_TYPE=postgresYes — point the instance at a managed PostgreSQL
Cache and queueIn-memory, or Redis with REDIS_ENABLED=trueYes — external Redis is supported
Media filesLocal disk, or S3 / MinIO with STORAGE_TYPE=s3Yes — S3-compatible storage is shareable
Live WhatsApp engine connectionIn-memory map in the API processNo by default; opt-in claim/lease fencing exists since v0.14.0 (experimental — see Multi-node operation)

The first three rows move state out of the container, onto networked services that survive restarts and could one day back multiple instances. The fourth row cannot move — it is the WebSocket or browser the process is actively holding.

For how to migrate each layer onto its networked counterpart, see the Migration Guide. The environment variables themselves are in Configuration.

Scale up, not out

Because you are capped at one instance, capacity comes from vertical scaling (a bigger host) and engine choice — not more replicas.

  • Add CPU and RAM. The default whatsapp-web.js engine runs a Chromium instance per session, so memory is usually the first ceiling you hit. More sessions and higher message volume both want more RAM.
  • Consider Baileys. ENGINE_TYPE=baileys is a browser-free WebSocket client with a much smaller per-session footprint, so you fit more sessions on the same hardware. Review the engine trade-offs in Sessions before switching a live deployment.
  • Move the shared layers off-box. Switch to PostgreSQL and Redis so the database and cache stop competing with the engines for the host's resources under load.
  • Run independent instances for isolation. Need more total sessions than one host carries, or hard tenant separation? Run separate OpenWA deployments, each with its own session-data volume and its own routing. They are independent single-instance deployments — they do not share live session state, and that is exactly why this is safe.
Rough starting points

These are unbenchmarked starting figures for the default whatsapp-web.js engine, not guarantees. Baileys needs considerably less. Always size from your own monitoring.

SessionsRAMCPU
1–52 GB2 cores
5–104 GB4 cores
10–208 GB8 cores

Know when you are at the ceiling

Watch host memory and CPU, and poll the health endpoints. /api/health returns the basic status; /api/health/ready returns 503 when a required database is unreachable or the instance is draining, which is what your orchestrator should gate traffic on.

The basic check is public — it takes no X-API-Key header — and reports liveness plus the running version (the version field only, since v0.19.0, is disclosed to requests carrying a valid API key):

curl http://localhost:2785/api/health
{
"status": "ok",
"timestamp": "2026-06-26T10:15:00.000Z",
"version": "0.23.1"
}

The readiness check is also public. It probes the auth/audit (main) and data databases and is what your orchestrator should gate traffic on:

curl -i http://localhost:2785/api/health/ready

When both databases respond, it returns 200 with each dependency marked up:

{
"status": "ok",
"details": {
"mainDatabase": { "status": "up" },
"dataDatabase": { "status": "up" }
}
}

When a required database is unreachable, it returns 503 with the failing dependency marked down — this is the response your probe will hit, so configure it to treat 503 as "not ready":

{
"status": "error",
"details": {
"mainDatabase": { "status": "down" },
"dataDatabase": { "status": "up" }
}
}

While the instance is draining during a graceful shutdown, it returns 503 with a shutdown detail even if the databases are still up, so traffic stops before teardown:

{
"status": "error",
"details": {
"shutdown": { "status": "draining" }
}
}

When memory sits high under steady load, you have three levers, in order of effort: switch sessions to Baileys, give the host more RAM, or split sessions across a second independent instance. Adding replicas to the same deployment is not on that list.

The session, message, and other resource endpoints you would call alongside these checks do require an X-API-Key header — see Authentication for how to obtain a key.

Multi-node operation (experimental)

As of v0.14.0, the groundwork for true horizontal scaling ships opt-in and experimental. Single-instance remains the supported default. The multi-node path is something you turn on by setting the right variables, and several gaps (listed at the end of this section) still keep it short of a fully supported topology.

Session ownership: claim and lease

Sessions now carry an owner (nodeId) and a renewed lease (leaseExpiresAt). A process claims a session before starting its engine and refuses when another node holds a live claim, so two replicas can no longer both launch the same session — which is what corrupted the shared LocalAuth directory in the first place. A booting process also reaps only the sessions it may claim, instead of reporting a peer's live sessions as disconnected. Claims are released on a clean shutdown and expire otherwise, so failover does not depend on a graceful exit.

NODE_ID names the owning process. It defaults to the hostname and must be stable across restarts — a value that changes makes the restarted process a new node, which has to wait out its own previous lease before it can reclaim.

Failover via the takeover sweep

A periodic takeover sweep (SESSION_TAKEOVER_SWEEP_MS, default 30000) adopts sessions whose holder's lease has lapsed — a crashed peer, or a recreated container whose new identity boots before its old lease expires. The sweep is gated by the same AUTO_START_SESSIONS flag as boot auto-start. Only authenticated sessions in a running-or-should-be state are adopted; mid-pairing and operator-failed ones are left alone, and a cleanly stopped session releases its claim so it is never seen as lapsed. Adopting a session fails its stuck in-flight bulk batches — there is no auto-resume, because what the dead node already sent is unknowable.

Request routing (opt-in via NODE_URL)

When every node sets its own reachable URL (for example NODE_URL=http://10.0.0.5:2785), a session-scoped request landing on a non-owner is forwarded to the live owner and the owner's response is relayed back (the response carries x-openwa-served-by naming the owner). The forward happens after API-key auth, carries the caller's credentials (both nodes share the auth database), is bounded by SESSION_PROXY_TIMEOUT_MS (default 60000), and is one hop only — a forwarded request is never forwarded again. A request that still lands on a live non-owner (stale ownership, or a forged hop marker) is refused with a retryable 409 rather than executed there. A lapsed owner is deliberately not forwarded to: the local node handles the request, which is exactly how a takeover begins.

Without NODE_URL the whole routing path is inert, and single-node deployments pay nothing for it.

Forwarded requests carry the client address in x-forwarded-for (the inbound chain preserved, the observed peer appended). For an allowedIps-restricted key or the per-IP throttler to see the real client on forwarded calls, list every peer node's address in each node's TRUSTED_PROXIES — otherwise the owner correctly ignores the chain and every forwarded request appears to come from the peer itself.

stop and delete answer 409 when a live peer is running the session (so a request landing on the wrong node no longer writes disconnected over a peer's engine or deletes its row while the peer keeps running). A lapsed claim still proceeds, since taking over is what the claim rule allows.

WebSocket event fan-out (requires Redis)

When REDIS_ENABLED=true, WebSocket events fan out across replicas through a Redis pub/sub adapter attached to Socket.IO — a client connected to node A receives an event raised on node B. Scope this honestly: it distributes fan-out only. Mid-connection key eviction (socketsByKeyId), the per-key WebSocket rate-limit buckets, and the engine registry all stay process-local. Without REDIS_ENABLED the adapter is inert and delivery is single-node, exactly as before.

Clocks must agree

The lease compares timestamps written by different nodes. Each node writes leaseExpiresAt from its own clock and reads every other node's the same way, so a node whose clock runs more than one lease TTL (SESSION_LEASE_TTL_MS, default 60000) ahead sees healthy peers as lapsed and takes their sessions over. Run NTP (or any time sync) on every node — the default on ordinary server images — and treat a skew larger than the lease TTL as a misconfiguration.

What still does not work

The multi-node path is experimental because real gaps remain:

  • Key eviction and per-key WebSocket rate-limit state are process-local. A key revoked on node A tears down only A's sockets; the per-key WS rate limit is counted per replica.
  • The liveness watchdog and reconnect timers act on whatever is in the local registry, not on a shared cluster view.
  • Bulk send batches cannot be resumed by a takeover. A batch driven by a dead node is failed, not continued.
  • MCP tool invocations execute on the node that received them, not on the owning node — so an agent tool call for a session owned by a peer will not reach that peer's engine.

Treat multi-replica examples — a Docker Swarm service with replicas: 3, or a Kubernetes StatefulSet whose pods share one session volume — as experimental until those land. Without the ownership layer enabled (every node setting NODE_URL, a stable NODE_ID, Redis for the event fan-out, and synchronized clocks), replicas: 1 is still the only correct value, and two instances sharing one session volume will corrupt WhatsApp authentication as described at the top of this page.

Next steps

  • Deployment — production Docker setup, health checks, and reverse-proxy TLS.
  • Migration Guide — move persistent data to PostgreSQL, cache to Redis, and media to S3 / MinIO.
  • Configuration — the DATABASE_TYPE, REDIS_ENABLED, STORAGE_TYPE, and ENGINE_TYPE variables referenced above.