Overview
WhatsApp-Rust uses a layered storage architecture with pluggable backends. ThePersistenceManager manages all state changes, while the Backend trait defines storage operations for device data, Signal protocol keys, app state sync, and protocol-specific data.
Architecture
PersistenceManager
Location:src/store/persistence_manager.rs
Purpose
Manages all device state changes and persistence operations. Acts as the gatekeeper for state modifications.Structure
device: In-memory device state (protected bytokio::sync::RwLock; write-locked only during mutations)device_snapshot: CachedArc<Device>rebuilt under the write guard on every mutation — read byget_device_snapshot()with no contention against writersbackend: Storage backend implementationdirty: Flag indicating unsaved changessave_notify: Notification channel for background saver (usesevent_listener::Eventfor runtime-agnostic operation)
Initialization
Key Methods
get_device_snapshot
Purpose: Read-only access to device statemodify_device
Purpose: Modify device state with automatic dirty trackingprocess_command
Purpose: Apply state changes viaDeviceCommand
Background Saver
Purpose: Periodically persist dirty state to disk- Wakes up when notified or after interval
- Only saves if dirty flag is set
- Uses optimistic locking (dirty flag)
Backend Trait
Location:wacore/src/store/traits.rs
Overview
TheBackend trait is automatically implemented for any type that implements all four domain-specific traits:
Domain Traits
SignalStore
Purpose: Signal protocol cryptographic operationsSignalStoreCache
Location:wacore/src/store/signal_cache.rs (re-exported from src/store/signal_cache.rs)
The SignalStoreCache provides an in-memory cache layer for Signal protocol state, matching WhatsApp Web’s SignalStoreCache implementation. All crypto operations read and write through this cache, with database writes deferred to explicit flush() calls.
Sessions and sender keys are cached as deserialized objects (SessionRecord and SenderKeyRecord respectively), matching WhatsApp Web’s pattern where the JS object IS the cache. Serialization only happens during flush() — not on every store_session or put_sender_key call. Identity stores use Arc<[u8]> byte caches.
- Session object cache: Sessions are stored as
SessionRecordobjects, eliminating protobuf encode/decode from the per-message path. Cold loads deserialize from backend bytes once and cache the object; subsequent reads return the cached object directly - Sender key object cache: Sender keys are stored as
SenderKeyRecordobjects (same pattern as sessions), eliminating serialize/deserialize from the per-message group encryption path. Cold loads deserialize from backend bytes once and cache the object Arcprevious sessions:SessionRecord.previous_sessionsis wrapped inArc<Vec<SessionStructure>>, making clone O(1) for the ~40 archived sessions. Only rare paths (archive, promote, take/restore) triggerArc::make_mut- Owned
store_session: Thestore_sessionmethod takesSessionRecordby value, enabling zero-cost moves from the protocol layer. The compiler enforces no use-after-store - Deferred writes: Changes are accumulated in memory and batch-written on
flush(). Sessions and sender keys are serialized only duringflush(), not on every store - Redundant write elimination: For identities (which rarely change),
put_dedup()compares incoming bytes against the cached value and skips if identical - Negative caching: Known-absent keys are cached as
Noneto avoid repeated DB lookups - Independent locking: Sessions, identities, and sender keys each have their own mutex
- O(1) key cloning: Keys stored as
Arc<str>so cloning a key is a refcount bump instead of a heap allocation. Thekey_for()method reuses existingArc<str>keys from the HashMap viaget_key_value(), avoiding heap allocation on the hot path - Single-allocation keys: Session lock keys use
to_protocol_address_string()(format:user[:device]@server.0) which builds the key string in one allocation, avoiding the two-allocation overhead of constructing aProtocolAddressthen calling.to_string(). See Signal Protocol performance for details
- Acquires all three mutexes to ensure consistency
- Sessions are serialized to bytes only during flush (not on every
put_session) - Only clears dirty tracking after ALL writes succeed
- On failure, dirty state is preserved for retry on next flush
AppSyncStore
Purpose: WhatsApp app state synchronizationcritical_block- Blocked contacts, push namesregular_high- Mute settings, starred messages, contact inforegular_low- Archive settings, pin settingsregular- Other chat settings
ProtocolStore
Purpose: WhatsApp Web protocol-specific storageLidPnCache
Location:src/lid_pn_cache.rs
The LidPnCache provides an in-memory cache for LID to phone number mappings, used for Signal address resolution. WhatsApp Web uses LID-based addresses for Signal sessions when available.
WAWebLidPnCache:
- Time-based expiry: none — entries do not idle out
- Capacity: effectively unbounded (
u64::MAX; no dedicatedunbounded()builder)
CacheEntryConfig can impose a capacity bound if memory pressure requires it. Be aware that capacity-LRU eviction silently downgrades Signal addresses from @lid (or @hosted.lid) back to @c.us, which can cause SessionNotFound decryption failures. Notably, this keeps status@broadcast participants resolving to @lid for the entire session, matching WA Web behavior.
Bidirectional lookups:
Since v0.6
get_current_lid (and the SendContextResolver::get_lid_for_phone trait method) returns Option<wacore_binary::CompactString> instead of Option<String>. This avoids a heap allocation on the hot group-send path, since LID identifiers fit inline in a CompactString. Call .as_deref() for &str comparisons, and update any custom SendContextResolver implementation to the new return type.created_at timestamp wins for the PN → LID lookup:
SignalStoreCache (not the backend directly) to avoid stale reads when the cache has unflushed mutations, then flushes the migrated state to the backend. This prevents SessionNotFound decryption failures when the phone switches from PN to LID addressing. See Signal Protocol — PN→LID session migration for details.
Learning sources:
usync- User sync responsespeer_pn_message/peer_lid_message- Peer messagespairing- Device pairingdevice_notification- Device notificationsblocklist_active/blocklist_inactive- Blocklist operations
DeviceInfo gained the is_hosted field, populated from usync device-list results. Use DeviceInfo::new(device_id, key_index).with_hosting(is_hosted) instead of a struct literal, and add a .. to any exhaustive destructuring pattern. Persisted JSON without is_hosted still deserializes correctly (is_hosted defaults to false). See Store: DeviceInfo and USync — Hosted addressing for details.
TcTokenEntry:
DeviceStore
Purpose: Device data persistenceserver_cert_chain field is populated after a successful XX (or XXfallback) handshake and consumed on the next connect to attempt Noise IK. It is cleared via DeviceCommand::ClearServerCertChain if a resumed IK handshake fails crypto-fatally. See Authentication — Noise Protocol Handshake.
The
account field uses a custom account_serde module to bridge buffa-generated protobuf types (which lack serde::Deserialize) into serde. It encodes ADVSignedDeviceIdentity to protobuf bytes on serialization and decodes them back on deserialization. The #[serde(default)] attribute ensures backward compatibility — old data missing this field deserializes as None.SqliteStore implementation
Location:storages/sqlite-storage/src/lib.rs
As of v0.5, the
whatsapp-rust-sqlite-storage crate bundles SQLite by default via the bundled-sqlite feature. You no longer need SQLite installed as a system dependency. To link against a system SQLite instead, disable the default features on the crate.BLOB encoding
Three BLOB columns use protobuf (viabuffa) for on-disk encoding:
Protobuf’s field-tagged wire format tolerates additive changes (adding or reordering fields) without corrupting persisted rows. The encode/decode helpers live in
storages/sqlite-storage/src/wire.rs; wacore domain types are untouched — conversion happens only at the storage boundary.
Upgrading from a bincode-encoded database
No manual migration is needed. The storage layer self-heals lazily on first startup after upgrade:- A BLOB that cannot be decoded as protobuf (a legacy bincode row or genuine corruption) is treated as absent rather than an error.
- App-state sync keys: the client discovers the account’s other devices and requests the missing key from all of them concurrently, falling back to the primary alone if device discovery fails or returns none, bounded by the same 10-second recovery budget as an active waiter. Whichever device replies first triggers the next
set_sync_keycall, which overwrites the row in protobuf format. - App-state versions: the collection resets to version 0 and re-syncs from the server. The first
set_versioncall overwrites the row in protobuf format. - Server cert chain: rebuilt on the next Noise XX handshake. The following
save_device_datacall overwrites the row in protobuf format.
Database schema
The device table uses named columns for each field, making the schema self-documenting and reducing the risk of column mix-ups when fields are added:Multi-Account Support
Each device has uniquedevice_id:
device_id:
Memory and Thread Tuning (SqliteStoreConfig)
Each session owns its own SqliteStore, so the store’s fixed costs (thread-pool threads, pooled connections, page cache) multiply with session count. On a host running many sessions the defaults from SqliteStore::new / new_for_device are deliberately low-memory; SqliteStoreConfig lets you override them when you need more throughput on a single session.
Default (low-memory) profile:
SqliteStoreConfig struct:
SqliteStore::new and SqliteStore::new_for_device are unchanged — they delegate to SqliteStoreConfig::default() internally. Raising pool_size above 1 also raises the internal semaphore permits in lockstep, so real concurrency is available without the store serializing every operation.
At ~100 sessions the r2d2 thread count drops from ~300 to 2.
DeviceCommand Pattern
Location:src/store/commands.rs, wacore/src/store/commands.rs
Purpose
Provide type-safe, centralized state mutations.Command Enum
Command Application
Usage
State management best practices
Read-Only Access
Modifications
Bulk Operations
Critical Errors
Custom backend implementation
Example: PostgreSQL backend
Usage
Migration & Debugging
Database Snapshots
Feature flag:debug-snapshots
Pluggable cache store
Location:src/cache_store.rs, src/cache_config.rs, wacore/src/store/cache.rs
Overview
By default, whatsapp-rust uses in-processPortableCache caches for group metadata, device lists, device registry, and LID-PN mappings. The pluggable cache store adapter lets you replace any of these with an external backend (Redis, Memcached, etc.) by implementing the CacheStore trait.
PortableCache
PortableCache is the client’s sole in-process cache backend on every target, including wasm32. It is a platform-agnostic, runtime-independent implementation.
PortableCache supports:
- Maximum capacity with oldest-inserted (FIFO) eviction
- Optional eviction guard — a capacity-only cache can register a predicate (
evict_guard) that protects entries a live task still holds (e.g. anArc<Mutex>mid-lock) from FIFO eviction. The per-device session lock cache (session_locks) uses this so a lock a task is actively holding is never evicted and re-minted under a different identity, which would let two writers race the same resource; if every entry is currently protected, the cache temporarily exceeds capacity rather than dropping a live one - Time-to-live (TTL) — entries expire a fixed duration after insertion
- Time-to-idle (TTI) — entries expire after a fixed duration of no access
- Monotonic expiry — TTL/TTI use
wacore::time::Instant(not the wall clock), so system-clock jumps cannot expire entries early - Single-flight
get_with— concurrent initializations for the same key coalesce into a single call, which is critical for caches storing coordination primitives (mutexes, channels) - Eager init-lock reclamation —
get_with/get_with_by_refdrop a key’s init lock once no other caller holds it, preventing unboundedinit_locksgrowth in high-cardinality caches (session locks, chat lanes, dedup) - Reliable async
clear()— awaits the write lock; prefer this over the best-effort syncinvalidate_all()in async contexts snapshot_entries()— reliable awaited snapshot of(Arc<K>, V)pairs for invalidation passes;iter()is a best-effort sync spin that can yield an empty snapshot under write contention
CacheStore trait
"group", "device", "lid_pn_by_lid"). Implementations should partition keys by namespace — for example, a Redis implementation might prefix keys as {namespace}:{key}.
Cache operations are best-effort. The client falls back gracefully when cache reads fail (treats as miss) and logs warnings on write failures.
TypedCache
TypedCache<K, V> is a generic wrapper that dispatches to either the in-process PortableCache or a custom CacheStore backend. The in-process path has zero extra overhead — values are stored in memory without any serialization. The custom-store path serializes values with serde_json and keys via Display.
CacheEntryConfig provides a build_typed_ttl convenience method that automatically selects the right backend: if a custom CacheStore is provided, it creates a TypedCache backed by the store; otherwise it falls back to an in-process PortableCache.
Arc<GroupInfo> so that warm sends and repeated query_info calls reuse the same snapshot instead of deep-cloning the participants list.
invalidate_all and clear
TypedCache provides two ways to remove all entries:
invalidate_all()— synchronous. For the in-process backend this is a best-effort spin; under sustained write contention it can silently skip the clear. For customCacheStorebackends, it spawns a fire-and-forget task viatokio::runtime::Handle::try_current(), which requires thetokio-runtimefeature. Withouttokio-runtimeenabled, the clear is skipped and a warning is logged.clear()— async. Awaits completion for custom backends and is the recommended approach when you need to ensure all entries are removed.
CacheStores configuration
TheCacheStores struct controls which caches use custom backends:
None keep the default in-process PortableCache behaviour. Use CacheStores::all(store) to set the same backend for all pluggable caches at once.
Coordination caches (
session_locks, chat_lanes), the signal write-behind cache, and pdo_pending_requests always stay in-process — they hold live Rust objects (mutexes, channel senders) that cannot be serialized to an external store.Usage with Bot builder
CacheStores::all() to route all pluggable caches to the same backend:
Granular cache patching
Instead of invalidating a cache entry and re-fetching from the server on every change notification, whatsapp-rust applies granular patches to cached values in place. This eliminates an extra IQ round-trip per update and keeps caches consistent in real time.How it works
All patching follows the same pattern: read the cached value, mutate it, and write it back. There is no atomic compare-and-swap — theget → mutate → insert sequence can race with concurrent notifications, but this is acceptable because the cache is best-effort and a full server fetch on the next read corrects any drift.
Patched cache domains
Granular patching is implemented in three areas: Device registry (src/client/device_registry.rs)
When a device notification arrives, the client patches the device_registry_cache (keyed by user string, stores DeviceListRecord):
patch_device_add— appends a new device JID to the cached list and persists the updatedDeviceListRecordto the backend storepatch_device_remove— removes a device by ID usingretain, then persistspatch_device_update— updateskey_indexon an existing device entry, then persists
patch_device_add method also performs ADV (Account Device Verification) key index filtering. When KeyIndexInfo is present, the signed bytes are decoded via wacore::adv::decode_key_index_list to extract valid device indexes, and stale devices not in the valid set are filtered out. If the raw_id in the notification differs from the stored value, the client detects an identity change and clears all Signal sessions for that user’s non-primary devices.
When a notification arrives with only a hash (no device list), that hash names the contact whose device list changed — not the notification’s from, which is always the local account. The client resolves the hash (base64(md5(user + "WA_ADD_NOTIF")[..3]), matching WA Web’s WAWebApiContact) against a reverse index the LidPnCache maintains from every LID/PN mapping it has learned, and queues only that contact’s device list for a refresh via the same schedule_unknown_device_sync() path used for deferred device sync — batched during an offline resume, immediate otherwise. An unresolvable hash (a contact never learned) refreshes nothing. Earlier, the client invalidated the notification’s own from instead, which discarded the local account’s own companion device list on every hash-only update and left the actually-changed contact stale.
LID migration: When a new LID-PN mapping is discovered, the device registry re-keys entries from the PN key to the LID key via migrate_device_registry_on_lid_discovery(). This ensures lookups by either addressing scheme resolve to the same canonical record. The migration also invalidates stale PN-keyed entries from both cache and database.
Group metadata (src/handlers/notification.rs, src/features/groups.rs)
When participant add/remove notifications arrive, the cached GroupInfo is patched in place:
- Participant adds use
GroupInfo::add_participants(), which deduplicates by user and backfills LID-to-PN maps for LID-addressed groups - Participant removes use
GroupInfo::remove_participants(), which also cleans up both LID-to-PN and PN-to-LID maps bidirectionally - API calls (
client.groups().add_participants(),client.groups().remove_participants()) also patch the cache after a successful server response, filtering to only participants the server accepted (status 200)
query_info() call re-fetches from the server.
Only leave() uses full invalidation.
LID-PN mappings (src/client/lid_pn.rs, src/lid_pn_cache.rs)
The LidPnCache uses timestamp-based conflict resolution when adding new mappings: the PN → entry map only updates if the new entry’s created_at is newer than or equal to the existing entry’s, preventing older stale mappings from overwriting newer ones.
On top of that, the write itself is gated by a source-aware write policy (lid_pn_write_policy, mirroring WhatsApp Web’s createLidPnMappings switch (learningSource)): the incoming pair’s LearningSource decides whether it’s even allowed to overwrite what’s cached, before the timestamp comparison ever applies.
- Directed sources (device-sync, peer messages, migration, blocklist) overwrite the cache on any change.
- Observational bulk sources (
Other,Pairing,DeviceNotification— e.g. history-sync’sphoneNumberToLidMappingsseed, group participant lists) only ever seed a new LID. If the pair conflicts with a LID the cache already knows for that phone, the write is skipped and the phone number is queued for a background live LID query (LidQuerySpec) instead — the result of that query is learned underUsync, which is a directed source and cannot itself re-trigger a reconcile, so there’s no query → learn → query loop. MigrationSyncOldandBlocklistInactiveare known-stale: they still write, but withcreated_at = 0, so a fresher mapping for the same phone keeps winning the PN→LID resolution.
Patching vs. invalidation summary
Related Sections
Architecture
Understand PersistenceManager’s role
Authentication
Learn how session data is persisted
Custom Backends
Implement your own storage backend
Storage API
Complete storage API reference