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
There is nostore.removeSession(id) API. The in-store session map is only cleared by store.destroy(). For long-running multi-tenant processes:
- Logout, keep the entry.
await client.logout()wipes the persistent state for thatsessionId(subject tologoutStoreClear). TheWaStoreSessionbundle stays in the in-store map — inert, but holding the per-domain stores until the process restarts. Acceptable when tenant churn is low relative to total memory. - Restart the process when you need to reclaim every byte (e.g. after deprovisioning many tenants at once). Destroy the store and rebuild.
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.
