Skip to main content

Overview

WhatsApp-Rust uses a layered storage architecture with pluggable backends. The PersistenceManager 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

Fields:
  • device: In-memory device state (protected by tokio::sync::RwLock; write-locked only during mutations)
  • device_snapshot: Cached Arc<Device> rebuilt under the write guard on every mutation — read by get_device_snapshot() with no contention against writers
  • backend: Storage backend implementation
  • dirty: Flag indicating unsaved changes
  • save_notify: Notification channel for background saver (uses event_listener::Event for runtime-agnostic operation)

Initialization

Key Methods

get_device_snapshot

Purpose: Read-only access to device state
Usage:

modify_device

Purpose: Modify device state with automatic dirty tracking
Usage:

process_command

Purpose: Apply state changes via DeviceCommand
Usage:

Background Saver

Purpose: Periodically persist dirty state to disk
Behavior:
  • Wakes up when notified or after interval
  • Only saves if dirty flag is set
  • Uses optimistic locking (dirty flag)
Start background saver:

Backend Trait

Location: wacore/src/store/traits.rs

Overview

The Backend trait is automatically implemented for any type that implements all four domain-specific traits:

Domain Traits

SignalStore

Purpose: Signal protocol cryptographic operations
Usage Example:

SignalStoreCache

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.
Key features:
  • Session object cache: Sessions are stored as SessionRecord objects, 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 SenderKeyRecord objects (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
  • Arc previous sessions: SessionRecord.previous_sessions is wrapped in Arc<Vec<SessionStructure>>, making clone O(1) for the ~40 archived sessions. Only rare paths (archive, promote, take/restore) trigger Arc::make_mut
  • Owned store_session: The store_session method takes SessionRecord by 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 during flush(), 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 None to 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. The key_for() method reuses existing Arc<str> keys from the HashMap via get_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. ProtocolAddress itself is allocation-free for addresses that fit inline (up to 47 bytes), but to_string() on top of it still allocates, so this remains the cheaper path when only the string is needed. See Signal Protocol performance for details
Cache operations:
Flush behavior:
  • 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
  • Deleted sessions, identities, pre-keys, and sender keys go through the batch methods (delete_sessions_batch, delete_identities_batch, remove_prekeys_batch, delete_sender_keys_batch) rather than one backend call per row
Batched deletes: Before whatsapp-rust#1395, every delete in flush was its own backend call — a spawn_blocking, a pool checkout, and a WAL commit each, roughly 60 µs on disk. That’s the shape an offline drain or an identity reset produces: many sessions, identities, or consumed pre-keys dropped in one flush. SqliteStore now overrides the four batch methods above with a single transaction and chunked IN lists; a backend that doesn’t override them keeps the old per-row loop as the default. A single deleted row costs the same either way — the warm steady state, which rarely deletes, is unaffected. Undecodable session rows: Deserialization is a pure function of the stored bytes. A session row that fails to decode once — from genuine corruption, or from a row written in a shape this build can no longer read, including a counter lease stranded by a since-fixed bug (see DH ratchet resets rebase the lease) — fails identically forever.
  • get_session, checkout_session, and has_session all report that row as absent instead of propagating a decode error. Loading the row doesn’t repair it by itself — it only lets the caller treat the address as session-less.
  • That matters because the paths that would otherwise repair the session — decrypting the peer’s next pre-key message, the retry-receipt handler — have to load the record first. If the decode error propagated instead, it would strand the address until you deleted the row by hand.
  • Reporting the row absent lets the ordinary no-session recovery run instead: the next send or decrypt for that address fetches a fresh pre-key bundle and persists a replacement session, overwriting the unreadable row. This build never derives key material from bytes it can’t decode, so it loses nothing usable — but the overwrite is destructive to the original bytes. If you’re rolling back to a build that could still decode that row, back up the database first; the original bytes don’t survive the overwrite.
  • has_session() decodes the row instead of only checking for its existence, so it never reports a quarantined row as present to a caller deciding whether to skip recovery.
  • Each quarantine increments the wa_session_record_quarantined_total counter. Watch for a non-zero rate — steady state is zero.

AppSyncStore

Purpose: WhatsApp app state synchronization
AppStateSyncKey Structure:
HashState structure:
Call state.has_baseline() to decide whether an incoming sync should ask the server for a snapshot or for patches. It checks for a real ltHash to sync against (bootstrapped || version > 0), which is a weaker condition than bootstrapped alone: a partial bootstrap that already advanced version has a baseline but did not complete. That’s why building an outgoing patch checks bootstrapped directly instead — it needs to know the bootstrap actually finished, not just that one started. Collections:
  • critical_block - Blocked contacts, push names
  • regular_high - Mute settings, starred messages, contact info
  • regular_low - Archive settings, pin settings
  • regular - Other chat settings

ProtocolStore

Purpose: WhatsApp Web protocol-specific storage
LidPnMappingEntry:

LidPnCache

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.
Defaults match WAWebLidPnCache:
  • Time-based expiry: none — entries do not idle out
  • Capacity: effectively unbounded (u64::MAX; no dedicated unbounded() builder)
A custom 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.
Timestamp conflict resolution: When multiple LIDs exist for the same phone number, the entry with the most recent created_at timestamp wins for the PN → LID lookup:
Initialization:
Session migration on LID discovery: When a new LID-PN mapping is added to the cache, the client automatically migrates any Signal sessions stored under the PN address to the corresponding LID address. The migration reads and writes through the 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 responses
  • peer_pn_message / peer_lid_message - Peer messages
  • pairing - Device pairing
  • device_notification - Device notifications
  • blocklist_active / blocklist_inactive - Blocklist operations
DeviceListRecord:
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 persistence
Device Structure:
The server_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.

ServerClientExpiration

server_client_expiration records the server’s <ib><client_expiration> deadline (see ClientExpirationChanged) — the date it expects to stop accepting the client build currently running. It is None until the server says otherwise, which is the common case: the stanza is sent when a build is being retired, not on every connect. Set through DeviceCommand::SetServerClientExpiration(Option<ServerClientExpiration>), applied by ServerClientExpiration::decide (wacore/src/store/device.rs) rather than written directly from the stanza’s t:
  • The raw t only gets acted on when it is sooner than the stored deadline. A t at or after the expires_at already held is treated as a stale retransmit or a host that hasn’t caught up: it’s ignored outright, and nothing is persisted or dispatched. This is a gate on the raw value, not a guarantee that the stored expires_at itself never moves later — see below.
  • At least three days of notice. Whatever the server says, the recorded expires_at is floored at now + 3 days, evaluated at the moment the stanza is accepted — so a server that says “now” still leaves a window in which the build can be replaced.
  • Scoped to the running build. A record left over from an earlier build (version doesn’t match the running (app_version_primary, app_version_secondary, app_version_tertiary)) is treated as absent rather than compared against — otherwise an old build’s nearer date could suppress the new build’s own, unrelated deadline.
  • A stanza with no t attribute withdraws the deadline (DeviceCommand::SetServerClientExpiration(None)), but only if one for the running build was actually held — withdrawing something never held is a no-op and dispatches no event.
The three-day floor is reapplied against “now” every time a t clears the gate above, and that interacts with the gate in a way worth knowing: an already-elapsed t (e.g. the server keeps resending t=<some past second>) stays “sooner than stored” indefinitely, so each resend clears the gate again and re-floors from the new “now” — pushing the stored expires_at later each time, even though the raw t never changed. A server that keeps signalling immediate expiry therefore holds a rolling three-day minimum-notice window rather than pinning one date. A dated t in the future, by contrast, settles once stored: restating it no longer clears the gate (it’s equal to, not sooner than, expires_at), so further repeats change nothing and dispatch nothing. Persisted as a single nullable TEXT column holding the record as JSON (see Database schema below) — a column per field buys nothing here, since nothing queries or orders by the version triple. A row that fails to decode (written by a newer build, or corrupted) reads as no deadline rather than failing the whole device load; the next <ib> restates it.
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 (via buffa) 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_key call, which overwrites the row in protobuf format.
  • App-state versions: get_version reads the undecodable row as None — never synced — rather than a persisted version 0, so the collection bootstraps from a fresh snapshot instead of requesting patches on top of an empty ltHash. The first set_version call overwrites the row in protobuf format.
  • Server cert chain: rebuilt on the next Noise XX handshake. The following save_device_data call overwrites the row in protobuf format.
Net effect: settings re-sync automatically on the first connection after upgrade, with no data loss and no manual DB intervention required.

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:
identities, sessions, prekeys, and sender_keys have no standalone index on device_id alone. A prior migration added one to each table when multi-account support landed, but every hot query already filters on the full primary key (address/id plus device_id), so that index only ever added a third b-tree write per inserted or deleted row without serving any selective read — device_id alone has one distinct value per account. whatsapp-rust#1395 drops all four (idx_identities_device_id, idx_sessions_device_id, idx_prekeys_device_id, idx_sender_keys_device_id); the only device_id-alone reads are account teardown, where a table scan is fine. whatsapp-rust#1411 applies the same reasoning to the last five standalone device_id indexes (idx_signed_prekeys_device_id, idx_app_state_keys_device_id, idx_app_state_versions_device_id, idx_app_state_mutation_macs_device_id, idx_base_keys_device) and drops them too — each table is PRIMARY KEY (..., device_id) and every query already filters on that key. In their place, two indexes that back real queries: idx_base_keys_created (device_id, created_at), read by the new delete_expired_base_keys sweep, and idx_sender_key_devices_device_jid (device_id, device_jid), which turned a full-table-scan delete on every peer device-removal notification (measured linear in row count — 770 µs at 10k rows, 5.39 ms at 70k) into a sub-25 µs index lookup regardless of table size.

Multi-Account Support

Each device has unique device_id:
All tables scoped by 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:
Constructors:
SqliteStore::new and SqliteStore::new_for_device are unchanged — they delegate to SqliteStoreConfig::default() internally. Keep pool_size at 1: raising it above 1 makes writes concurrent, which SQLite does not want — two deferred transactions that both read and then write deadlock on the upgrade, and busy_timeout cannot break it. Reach for read_pool_size (set via the with_read_pool_size builder, or the struct field directly) instead — that’s your knob for concurrency: it reserves connections purely for reads, which WAL lets run alongside a pending write without contending for the write lock (on a non-WAL connection your reads queue on pool_size regardless of this setting). The default became 1 reader as of whatsapp-rust#1401 — a get_session read during a write-behind flush measured p50 7.2 ms / p99 22.8 ms at read_pool_size: 0 against 0.17 ms / 4.0 ms with one reader. Set it to 0 to fall back to the pre-#1401 behavior, queuing your reads on the same permit as writes; do this if you’re holding many per-session stores that read rarely, to save the reader’s page cache.
As of whatsapp-rust#1222, read_pool_size also covers most of SqliteStore’s own SignalStore/AppSyncStore/ProtocolStore/DeviceStore reads, not just an application-level sibling store’s queries (see SqliteStore::shared()). Session, identity, sender-key, and pre-key lookups on the decrypt path — get_session, load_identity, get_sender_key, load_prekey, and similar — now run on the reader pool. Before this change they queued behind pool_size’s write-path permits even with read_pool_size set, so this raises the ceiling on read concurrency during a write-behind flush. A handful of reads stay on the write queue by design: app-state sync key lookups, messageSecret reads, and get_devices among them, since a stale answer for these would go out on the wire, fail an operation outright, or get promoted into a cache unconditionally. Widening read_pool_size past 0 now buys concurrency for most of the read surface, not just chat/message queries.
Multi-session measurements (from PR #926): At ~100 sessions the r2d2 thread count drops from ~300 to 2. The other profile: one long-lived session, one large database. Everything above tunes for a host running many sessions, where the defaults are deliberately low-memory. A process that pairs once and stays connected for weeks — a bot — is the opposite shape (whatsapp-rust#1411): there is one store, not fifty, and its database reaches a few hundred MB (msg_secrets dominates it; its msg_secret_retention horizon is what sets the size). Against a file that large, the default 512 KiB page cache is a fraction of a percent, so nearly every b-tree descent is an OS read, and one reader connection means the decrypt path queues behind whatever write is in flight. The default isn’t raised for everyone because the many-small-databases case is real and pays for both in memory; name the profile instead:

Sharing one pool across sibling devices

The measurements above still assume one SqliteStore per session. If your sessions share the same database file, call SqliteStore::share_for_device instead. It removes the per-session connection entirely rather than just shrinking its cost — a sibling store clones the base store’s pool, write semaphore, and reader pool instead of opening its own. Measured on an idle session (a couple of point reads, cache_size_kib at its 512 KiB default): You pay for this on the write side. Siblings share the write permit set by pool_size, so at the default of 1 their writes serialize against each other. On a continuous-write burst that costs roughly 2.5x slower aggregate write throughput. In exchange you get FIFO-fair scheduling across siblings instead of SQLite’s busy-handler backoff. Reach for it when your fleet is mostly idle, not when sessions write continuously. See Sharing the pool with sibling devices for the full trade-off and what the method does not do.

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
Usage:
Output:

Pluggable cache store

Location: src/cache_store.rs, src/cache_config.rs, wacore/src/store/cache.rs

Overview

By default, whatsapp-rust uses in-process PortableCache 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. an Arc<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. When you look up an entry, the cache renews its idle deadline lazily rather than on every access, so a hot key stays on the read path instead of taking a write lock per read. The recorded access time can lag your latest access by up to 1/16 of the TTI, so the entry may idle out up to that much early. The cache never serves it past its TTI
  • 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 reclamationget_with/get_with_by_ref drop a key’s init lock once no other caller holds it, preventing unbounded init_locks growth in high-cardinality caches (session locks, chat lanes, dedup)
  • Reliable async clear() — awaits the write lock; prefer this over the best-effort sync invalidate_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

Each logical cache uses a unique namespace string (e.g., "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.
The group cache stores 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 custom CacheStore backends, it spawns a fire-and-forget task via tokio::runtime::Handle::try_current(), which requires the tokio-runtime feature. Without tokio-runtime enabled, 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.
If you disable the tokio-runtime feature and use a custom CacheStore backend, invalidate_all() will silently skip clearing the external store. Use the async clear() method instead.

CacheStores configuration

The CacheStores struct controls which caches use custom backends:
Fields left as 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

Or use CacheStores::all() to route all pluggable caches to the same backend:
See Custom backends guide for a full implementation example.

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 — the get → 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 updated DeviceListRecord to the backend store
  • patch_device_remove — removes a device by ID using retain, then persists
  • patch_device_update — updates key_index on an existing device entry, then persists
All three methods iterate over every known PN/LID alias for the user so stale alternate-key entries stay consistent. Device patches also persist to the backend store immediately, so changes survive cache eviction and restarts. The 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)
Group patches are cache-only — they are not persisted to the backend. If the cache entry is evicted, the next 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’s PN/LID harvest, 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 under Usync, which is a directed source and cannot itself re-trigger a reconcile, so there’s no query → learn → query loop.
  • MigrationSyncOld and BlocklistInactive are known-stale: they still write, but with created_at = 0, so a fresher mapping for the same phone keeps winning the PN→LID resolution.
This means a bulk/observational seed can no longer clobber a freshly, authoritatively learned LID for a peer — it self-heals through the same live-query path WA Web uses instead of last-write-wins. History sync’s Other-sourced seed itself draws from two places inside the sync: the bulk HistorySync.phoneNumberToLidMappings block, and each conversation’s own pnJid/lidJid fields. A conversation named in one namespace can still yield a complete pair from just the opposite field. In practice the bulk block omits pairs the conversations carry, so both are harvested. History sync’s own harvesting logic collapses the two sources to one pair per LID before either reaches LidPnCache:
  • The bulk block is the server’s explicit identity table, so it outranks a conversation-derived pair naming the same LID or phone number.
  • Ties within one source keep the first occurrence.
This resolution happens ahead of, and independently from, the source-aware write policy above. LidPnCache never sees more than one candidate pair per LID out of a single history sync (#1345). By default this cache neither idles out nor evicts for capacity — see LidPnCache above: no time-based expiry, and capacity effectively unbounded, matching WAWebLidPnCache. So an entry is only ever corrected, never expired: lookups are cache-aside, and nothing proactively re-checks a mapping just because it might have gone stale. A mapping that quietly drifts (a peer re-pairing, an account migrating) would otherwise read as valid for the life of the process. The server closes that gap by flagging it directly. A send-message ack can carry refresh_lid="true". On that flag, the client re-resolves the acking peer through the same live query described above, keyed by phone number rather than the LID under suspicion — asking about the LID itself would look the answer up under the very key that might be wrong. A burst of acks for the same stale peer is coalesced to one query in flight. See Client — refresh on refresh_lid acks (#1313).

Patching vs. invalidation summary

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