Skip to main content
zapo is designed so a single process can drive many accounts off one shared store. Each account lives behind a stable sessionId; everything that’s safe to share (the backend connection pool, the WebSocket factory, the logger) is shared, and everything that’s account-specific (Signal sessions, identities, app-state, mailbox) is partitioned by sessionId.

The pattern

sessionId is the durable key for an account — same id across restarts resumes the same paired device. Changing it orphans the previous credentials.

What’s per-session vs shared

Switching to a multi-tenant setup is a matter of (1) instantiating N WaClients on the same store, and (2) sizing your backend pool + memory budget for N concurrent sessions.

Session lifecycle

store.session(sessionId) is memoized. The first call materializes the per-domain bundle (per-session locks, optional cache wrappers, …) and caches it inside the store; later calls with the same id return the same bundle.
WaClient calls store.session(sessionId) on demand; you do not usually call it yourself.

Adding tenants on the fly

There is no preregistration step — just construct a new WaClient with a new sessionId:

Removing tenants

For long-running multi-tenant processes, three options — each with a different scope:
  • await client.logout() — logical removal. Wipes the persistent state for that sessionId (subject to logoutStoreClear) and unlinks the device server-side. The bundle stays in the in-store map until you destroy it or the process ends; a subsequent WaClient({ store, sessionId }) on the same id reuses the same bundle.
  • await storeSession.destroy() — mid-process reclaim. Tears down the session’s per-domain stores and evicts the bundle from the in-store map (the sessionId is released as destruction starts), so a concurrent or later store.session(id) builds a fresh bundle. The teardown is idempotent — repeat calls await the same in-flight promise — and teardown failures are logged, never thrown.
  • await store.destroy() — process shutdown. Tears down every live session and the registered backends. store.session() throws afterwards; the store is single-shot.
For a bounded reset that keeps the session alive, await storeSession.destroyCaches() swaps the cache domains (retry / groupMetadata / deviceList / messageSecret) for freshly-built instances instead of closing them — useful when you want to drop stale entries from a persistent cache backend without losing the session. Concurrent resets are serialized and cache references captured before the call reject afterwards, so recreate the client to pick up the fresh cache stores.
session(id) returns the same bundle instance while it’s alive — so a WaClient holding a stale reference before you destroyed the session keeps hitting the closed bundle. In practice: destroy the client’s connection first (client.disconnect()), then session.destroy(), then build a new client if you want the same sessionId back.

Process ownership

In multi-process deployments, decide how sessionIds map to processes:
  • One process per sessionId via consistent hashing / sticky routing on the load balancer or queue (simplest).
  • Leader election before opening the client (a Postgres advisory lock, Redis SET NX, etcd lease) — useful for HA failover.
The opt-in cacheLayer tightens this: its L1 has no cross-process invalidation channel, so a sessionId’s backend rows should be owned by one process across its lifecycle. A takeover process’s L1 starts cold and may serve stale reads before catching up to writes the previous owner made.

Sharing a media processor

WaMediaProcessor is a stateless wrapper around your media binaries (sharp, ffmpeg/ffprobe, file-type). The same instance can serve every WaClient — there is no per-session state inside the processor, so reusing it avoids paying the binary-lookup / lazy-import cost N times.
Each processor method receives an optional ctx: WaMediaProcessorCallContext argument carrying that call’s Logger. The runtime fills it with the calling session’s logger, so warnings (missing binary, failed detectMimetype, …) land with the right per-session bindings automatically — no setup needed. Custom processors should consume ctx.logger per call and not cache it, since the same instance is shared across sessions.

Memory budget

WaCreateStoreOptions.memory.limits caps apply per session. With N concurrent sessions, the worst-case in-process RAM scales linearly: Tune the per-session caps downward as N grows, or move the mailbox/large-cardinality domains to a persistent backend (the in-memory provider exists for tests and small accounts). TTLs in memory.cacheTtlMs are independent of N — they only cap how long an entry survives in each cache.

Sharding strategies

@zapo-js/store-sqlite is single-host only and the SQLite file is held by one process — pick one of the network backends for any layout with more than one process.

Graceful shutdown

client.disconnect() flushes the per-session write-behind queue and closes the socket without unlinking the device, so the next boot resumes from the store. store.destroy() then releases the shared backend (pool, file handle, …). Calling disconnect() on every client before store.destroy() ensures each session’s pending writes flush; store.destroy() does not do that for you.
Don’t substitute logout() for disconnect() here — logout() unlinks the device server-side and clears stored state. Use it only when you intentionally want the account removed.

See also

  • Stores — the per-sessionId persistence model and the optional read-through cache layer.
  • Production & deployment — broader operational checklist (logging, timeouts, security).
  • Reconnection — reconnection policy applies per session; there is no shared reconnection loop.
Last modified on July 27, 2026