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 newWaClient 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 thatsessionId(subject tologoutStoreClear) and unlinks the device server-side. The bundle stays in the in-store map until you destroy it or the process ends; a subsequentWaClient({ 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 (thesessionIdis released as destruction starts), so a concurrent or laterstore.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.
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 howsessionIds map to processes:
- One process per
sessionIdvia 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.
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.
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.
See also
- Stores — the per-
sessionIdpersistence 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.
