Skip to main content

Overview

whatsapp-rust uses a trait-based storage system to persist device state, cryptographic keys, and protocol metadata. The storage layer is split into five domain-specific traits:
  • SignalStore - Signal protocol cryptographic operations (identity keys, sessions, pre-keys, sender keys)
  • AppSyncStore - WhatsApp app state synchronization (sync keys, versions, mutation MACs)
  • ProtocolStore - WhatsApp protocol alignment (SKDM tracking, LID-PN mapping, device registry)
  • MsgSecretStore - messageSecret persistence for poll/edit/bot-reply decryption (added in v0.6)
  • DeviceStore - Device persistence operations
All five traits are combined into the Backend trait for convenience.

The backend trait

Any type implementing all five domain traits automatically implements Backend:
MsgSecretStore became a required member of Backend in v0.6, so a custom backend must implement it (the bundled SqliteStore already does). Its methods have defaults that keep the surface small — see MsgSecretStore for which methods you actually need to write.

SignalStore Trait

Handles Signal protocol cryptographic storage for end-to-end encryption.

Identity Operations

Session Operations

PreKey Operations

Signed PreKey operations

Sender key operations

For group messaging encryption:
delete_identities_batch, delete_sessions_batch, remove_prekeys_batch, and delete_sender_keys_batch were added so SignalStoreCache::flush can drop many rows in one backend call instead of one call per row — the shape an offline drain or an identity reset produces, where the flush deletes dozens of sessions, pre-keys, or sender keys at once. Each has a default that loops over the single-item method, so existing custom backends keep working unchanged; the bundled SqliteStore overrides all four with one transaction and a chunked SQL IN list. Override them the same way if your backend supports transactions — see Batched deletes for measured numbers.

AppSyncStore Trait

Handles WhatsApp app state synchronization storage.

Sync key operations

Version Tracking

get_version returns Option<HashState> — previously it returned HashState, with a missing row silently coerced to HashState::default(). The absence is meaningful. WA Web treats “no record” as needing a bootstrap snapshot. A collection that synced and is legitimately empty sits at version 0 with a record instead, and asks for patches. Those two states used to be indistinguishable, and collapsing them made an empty collection re-request a snapshot forever. delete_version is new in the same release: it’s how you express a rebuild, by removing the record entirely rather than zeroing it, so the next sync bootstraps from scratch. If you maintain a custom backend, update get_version’s return type and implement delete_version.HashState also gained a bootstrapped: bool field. #[serde(default)] only helps a self-describing format — it lets an existing row decode this field as false. It doesn’t help every encoding: bincode, for example, reads a fixed field sequence and fails outright on a row written before the field existed, rather than defaulting it. A custom backend using a non-self-describing format needs to treat that decode failure as an absent row (see Upgrading from a bincode-encoded database) or migrate its stored rows to backfill the field. bootstrapped is set only once a bootstrap run reaches its terminal page — a deferred or partial bootstrap leaves it false even though the version already moved. Before building an outgoing patch, the client checks bootstrapped directly, and syncs the collection first if it’s not set. HashState::has_baseline() (bootstrapped || version > 0) answers a related but different question — whether there’s a real ltHash to sync patches against at all — and decides whether an incoming sync asks for a snapshot or for patches.

Mutation MAC Operations

get_mutation_macs was added in v0.6 to collapse the app-state sync’s per-mutation previous-MAC lookups (which were N+1) into a single batched query. It has a default implementation, so custom backends that do not override it keep working without changes — override it with a WHERE index_mac IN (…) query for the performance win. The SQLite store chunks the IN list at 500 entries.An index MAC is always a full HMAC-SHA256 output, so this method’s signature uses inline [u8; 32] arrays (wacore::appstate_sync::IndexMac) instead of Vec<u8> — zero per-MAC heap allocations on either side of the batch lookup. This is a breaking change for custom backends that override get_mutation_macs: update the parameter and return-map key types to [u8; 32]. get_mutation_mac, put_mutation_macs, and delete_mutation_macs are unaffected.
commit_patch was added in whatsapp-rust#1401 so the app-state sync loop can persist a patch’s version and MAC changes in one backend call instead of three, replacing the sync loop’s previous sequence of a set_version call followed by conditional delete_mutation_macs/put_mutation_macs calls. Its default implementation matches that sequence, so your existing custom backend compiles and behaves the same without changes. Override it with a single transaction if your backend supports them (the SQLite store does): a paged incremental sync commits hundreds of small patches, and each of the three separate writes previously paid its own connection permit and commit. A transactional override is also strictly safer for you: the new version can no longer land without the MACs it pairs with.

ProtocolStore Trait

Handles WhatsApp protocol alignment and tracking.

Per-device sender key tracking

Tracks sender key distribution status per device in groups, matching WhatsApp Web’s participant.senderKey Map<deviceJid, boolean> model. Each device has a boolean indicating whether it holds a valid sender key (true) or needs a fresh SKDM (false).

LID-PN Mapping

Manages mappings between LID (Locally Indexed Device) and phone numbers:

Base key collision detection

delete_expired_base_keys was added in whatsapp-rust#1411. A base key is written when a peer’s retry #2 arrives and previously had no deletion path for the common case where no retry #3 ever follows — the row stayed for the life of the database. The client now sweeps rows older than one hour on a keepalive tick (see Retention and periodic maintenance); the built-in SqliteStore and InMemoryBackend both implement the sweep, so only a fully custom backend needs to override the default no-op.

Device Registry

get_devices_batch was added in whatsapp-rust#1400 so resolving a cold large group’s devices (get_user_devices_owned) and the usync response path read every member in one round trip instead of one get_devices call per member. It has a default implementation that loops, so existing custom backends compile and behave the same without changes. Override it with a single chunked IN (...) query if your backend supports one — the built-in SqliteStore does, and the exploration behind this PR measured the per-member form at 22.1 ms against 0.68 ms for one query at 256 members on a file-backed store.

TcToken Storage

Trusted contact privacy tokens:
get_tc_tokens was added in whatsapp-rust#1405 so the reconnect presence re-subscribe (see Automatic re-subscription on reconnect) can look up a whole batch of tracked contacts’ tokens in one backend call instead of one query per contact. It has a defaulted implementation, so existing custom backends compile and behave the same without changes — override it with a WHERE jid IN (...) query for the performance win, the same way SqliteStore chunks it at 500 entries.
delete_expired_tc_tokens gained a second sender_cutoff parameter — this is a breaking change for any custom backend that overrides it; update the signature to (&self, token_cutoff: i64, sender_cutoff: i64) -> Result<u32> and prune on both windows independently (see the built-in SqliteStore/InMemoryBackend implementations for the two-filter pattern).touch_tc_token_sender_timestamp and store_received_tc_token are defaulted methods — existing custom backends compile and work unchanged, but should override both with an atomic upsert if the backend supports one, since the default read-modify-write can race a concurrent writer touching the same row (post-send issuance vs. an incoming privacy_token notification). For store_received_tc_token specifically, a non-atomic override that races two callers can let an older token’s write land last and clobber a fresher one — the built-in backends close this by making the newer-wins check part of the same read+write.

Sent message store

Persists sent message payloads for retry handling. Matches WhatsApp Web’s getMessageTable pattern where retry receipts look up the original message from storage.
The take_sent_message method is an atomic read-and-delete operation. Once a message payload is taken for retry, it is removed from storage to prevent double-retry. For status broadcasts where multiple devices may retry, the client re-adds the message after taking it.

MsgSecretStore

The fifth required member of Backend. It persists the 32-byte messageSecret values needed to decrypt later add-ons keyed off an original message: poll votes, poll/event edits, message edits (secret_encrypted_message), and Meta AI / fbid bot replies (<enc type="msmsg">). Secrets are keyed by (chat, sender, msg_id) and carry an absolute expiry so they can be pruned by policy (see messageSecret retention).
get_msg_secret and get_msg_secret_with_ts still return Vec<u8> rather than MessageSecret. Reads don’t carry the same fixed-length guarantee as writes, since the persisted secret BLOB column has no length constraint at the SQL level.
The chat, sender, and msg_id fields are Arc<str> rather than String. This allows buffered batch inserts to clone entries cheaply. The secret field uses the fixed-size MessageSecret array rather than Vec<u8>. This makes an invalid-length secret unrepresentable, so you no longer need a runtime length check. This is a breaking change if you build MsgSecretEntry directly in a custom backend, or if you override the defaulted put_msg_secret method. If you construct entries directly, build the JID/ID fields with Arc::from(...) or .into(), and pass a [u8; 32] for secret. If you override put_msg_secret (most backends don’t — see the Note below), update its signature to take secret: &[u8; 32] instead of secret: &[u8]. The SQLite table itself is unchanged:
A custom backend only needs to implement three methods: put_msg_secrets, get_msg_secret, and delete_expired_msg_secrets. The other two have defaults — put_msg_secret delegates to put_msg_secrets with expires_at = 0, and get_msg_secret_with_ts pairs get_msg_secret with a 0 timestamp. Override get_msg_secret_with_ts only if your store persists message_ts and you want the edit-window enforced.

DeviceStore Trait

Handles device data persistence:

resource_report

Best-effort process-local memory this backend attributes to the session. Defaulted — most custom backends don’t need to implement it. It exists so a backend that can introspect its own memory (like SqliteStore’s SQLite page cache, often the single largest per-session chunk since it lives entirely outside the Client) can report it for Client::resource_report(). Remote/store-backed backends (Redis, etc.) should override it to return memory_bytes: Some(0) — their data isn’t process memory — rather than leaving the default all-None (“not reported”), if they want to make that positive claim explicit. As of whatsapp-rust#1235, InMemoryBackend overrides this default rather than inheriting it. Every byte it holds is this process’s heap, so unlike a file- or network-backed store it reports an exact figure rather than a cap: memory_bytes sums each internal map’s table allocation plus the heap its keys and values own (deduplicating the chat/sender strings shared across a conversation’s messageSecret rows), and pages carries the total row count across all of it. Measured against a counting global allocator, the reported total tracks live heap to within about 1%. If you implement a custom in-process backend (not store- or network-backed), overriding resource_report() the same way is worth it for the same reason SqliteStore does — otherwise Client::resource_report() silently under-counts that session. As of whatsapp-rust#1411, StorageResourceReport also carries free_pages: Option<u64> (pages on the store’s free list — SQLite: freelist_count) and wal_bytes: Option<u64> (the write-ahead log’s on-disk size). See Client::resource_report() for the full field list.

maintenance

Periodic engine upkeep the client calls on a coarse timer (roughly hourly) while connected — statistics refresh, log truncation, whatever a backend needs to stay in shape across a session measured in weeks rather than minutes. Defaulted to a no-op, for the same reason as resource_report: most custom backends don’t need it. It must be cheap enough to run on a live connection and safe to call when nothing has changed; anything that takes an exclusive lock on the whole database (SQLite’s VACUUM) belongs in an explicit embedder call instead, not here. Added in whatsapp-rust#1411. SqliteStore’s implementation runs PRAGMA analysis_limit = 400 followed by PRAGMA optimize (a no-op unless a table changed materially since the last ANALYZE), then an opportunistic PRAGMA wal_checkpoint(TRUNCATE) — the only checkpoint mode that returns the -wal file’s blocks to the filesystem. A TRUNCATE checkpoint declines rather than blocks when a reader still holds a snapshot, so a skipped truncate is a normal outcome, not a failure.

Retention and periodic maintenance

As of whatsapp-rust#1411, the client drives three cadences off the keepalive tick while a connection stays up, rather than relying on reconnects to reach them:
  • Retention sweeps (delete_expired_sent_messages, delete_expired_pending_inbound, delete_expired_base_keys, delete_expired_msg_secrets) run in one task, sequentially, on every keepalive tick that already drives the cache sweep.
  • Session maintenance (~6 hours) — signed pre-key rotation (see Signed pre-key rotation (RotateKeyJob)) and tcToken pruning (see Startup pruning) — previously ran only from the connect-time background init, so a session held open past their own cadences without a reconnect never reached them.
  • Engine maintenance (~1 hour) calls DeviceStore::maintenance() above.
All three are best-effort: a failing sweep or pass logs a warning and is retried on the next tick rather than affecting the connection.

SqliteStore implementation

The default storage implementation using SQLite with Diesel ORM. SQLite is bundled by default — you don’t need it installed on your system.

Bundled SQLite

The whatsapp-rust-sqlite-storage crate enables the bundled-sqlite feature by default, which compiles SQLite from source and statically links it. To use a system-installed SQLite instead:
Cargo.toml

Creating a store

Sharing the pool with sibling crates

SqliteStore::shared() returns a clonable SharedSqlite handle onto the store’s existing r2d2 connection pool and write-serialization semaphore. A sibling crate uses it to run its own queries and migrations against the same database file — without opening a second connection pool, which would mean two WAL writers contending for the file lock. An application-level store — a chat/message history store, for example — can use this to attach its own tables to the same whatsapp.db file as the device store.
read takes a permit from a separate reader pool, sized by SqliteStoreConfig::read_pool_size, which defaults to 1 connection as of whatsapp-rust#1401 (it was 0 before). A burst of reads can then run alongside a pending write instead of queueing behind run’s write-path permits. If read_pool_size is 0 or the underlying connection isn’t WAL, read falls back to queueing on the same permits as run — it’s always safe to call. Wrapping the closure in a deferred transaction also means a read that issues more than one statement (e.g. resolve a chat’s identity keys, then query by them) sees one consistent snapshot across all of them, rather than possibly straddling a write that commits in between. Prefer read over run for anything that only queries. Most of SqliteStore’s own SignalStore/AppSyncStore/ProtocolStore/DeviceStore surface does, as of whatsapp-rust#1222 — session, identity, sender-key, and pre-key lookups among them. A handful of reads are deliberately kept on run instead: a stale answer for these would go out on the wire, fail an operation outright, or overwrite a cache unconditionally (app-state sync key lookups, messageSecret reads, get_devices).

Sharing the pool with sibling devices

Call SqliteStore::share_for_device() to get a new SqliteStore for a sibling device in the same database file. It clones this store’s pool, write-serialization semaphore, and reader pool instead of opening its own. Without it, every constructor builds its own r2d2 pool, so a process holding N sessions against one database file opens N pools. At the default pool_size of 1 and read_pool_size of 1 (as of whatsapp-rust#1401; read_pool_size defaulted to 0 before), that means 2N connections; a store configured with a larger pool_size or read_pool_size opens more per session, and sharing removes that entire pool, not just one connection. A connection costs memory before it reads a single row. On the bundled SQLite build, that includes a fixed ~46.9 KiB lookaside slab (SQLITE_DEFAULT_LOOKASIDE) — a compile-time setting, so a system-linked or SQLCipher-enabled SQLite (see Connection init hook) can size it differently, or not allocate it at all. It also includes a page cache that grows to cache_size_kib. You can’t shrink the lookaside slab with a pragma — SQLITE_DBCONFIG_LOOKASIDE is C-API only and diesel doesn’t expose it. Since every query already carries a device_id, sibling sessions on one database only ever needed that field to differ.
The returned store owns clones of the pool handles, so you can keep using it for as long as it lives — dropping the store it came from closes nothing. What it does not do:
  • Create the device row. It only stamps queries with device_id. Wrap the returned store in PersistenceManager::new and it provisions the row for you automatically, the same as it would for a store built from new_for_device — you only need to provision the row yourself if you use the store directly, outside PersistenceManager.
  • Isolate writes. Siblings share the write permits set by SqliteStoreConfig::pool_size. At the default of 1, their writes serialize against each other. On a burst where every sibling writes continuously, sharing costs roughly 2.5x the aggregate write throughput of a pool per session. In exchange you get FIFO-fair scheduling across siblings. A private connection per session instead leaves ordering to SQLite’s busy-timeout handler, whose retries on each connection aren’t coordinated with any other connection’s — producing about 2x the spread between the fastest and slowest session.
  • Split resource_report(). Siblings share one pool, so every handle reports the same whole-pool estimate. When you sum across a fleet of siblings, count it once per pool, not once per handle.
Because of the write-serialization trade, reach for this with mostly-idle fleets — sessions that are connected but not writing continuously, which is the common shape — rather than as a default replacement for a store per session. See Memory and Thread Tuning for measured numbers.

Features

  • Connection pooling - Uses Diesel r2d2 with pool size of 2
  • WAL mode - Write-Ahead Logging for better concurrency
  • Automatic migrations - Runs embedded migrations on startup
  • Semaphore-based locking - Prevents concurrent writes
  • Retry logic - Automatic retry with exponential backoff for locked database
  • Multi-device support - Single database can store multiple device sessions

Database Configuration

SqliteStore automatically configures connections with:
A WAL grows to the largest single transaction ever committed and, with no limit set, stays that size for the life of the file — an auto-checkpoint only resets the WAL, it never shortens it. The history-sync msg_secrets seed is exactly that kind of transaction, so a month-long process would otherwise pay its peak size forever. journal_size_limit caps it at 32 MiB, well above any ordinary commit here, so it only ever trims the outlier; combined with the opportunistic wal_checkpoint(TRUNCATE) in DeviceStore::maintenance(), the WAL is brought back under the cap on the ~1-hour engine maintenance cadence. Added in whatsapp-rust#1411.
SqliteStoreConfig also exposes an opt-in mmap_size: Option<u64> field (default None, current behavior — no PRAGMA mmap_size emitted). Set it with the builder-style with_mmap_size(bytes):
with_mmap_size only sets the field on the config value — the store applies it when the config is passed to SqliteStore::with_config (or with_config_for_device). Building a config and never passing it to one of those constructors leaves mmap_size unset, since SqliteStore::new / new_for_device always use SqliteStoreConfig::default(). When set to a non-zero value, this emits PRAGMA mmap_size = <bytes>;, moving reads of the main database file through a reclaimable, OS-backed memory map instead of the heap page cache — useful for a process holding many small per-session databases, since mapped pages can be reclaimed under memory pressure while heap-cached pages cannot. 0 disables mmap, same as None.
mmap I/O covers reads of the main database file only. In WAL mode (this store’s default), writes still go through the WAL, and a checkpoint briefly falls back to non-mmap I/O. SqliteStore::resource_report() (see DeviceStore::resource_report) does not account for mmap_size — with mmap enabled, some reads bypass the heap page cache it measures, so the reported estimate can overstate actual process-heap residency for that session.

Connection init hook

SqliteStoreConfig also exposes an optional connection_init: Option<ConnectionInitHook> field, set via the builder-style with_connection_init(hook). The hook runs first in r2d2’s on_acquire customizer on every pooled connection — before the store’s own pragmas, and (because WAL setup and migrations also run on a pooled connection) before those too:
The canonical use is SQLCipher-style keying, where PRAGMA key must be the first statement executed on a fresh connection, ideally followed by a verification query:
Linking a SQLCipher-enabled SQLite is the caller’s responsibility: disable this crate’s default bundled-sqlite feature and depend on libsqlite3-sys with a SQLCipher build (e.g. its bundled-sqlcipher feature) instead. The crate itself gains no SQLCipher, key-type, or zeroization coupling — the hook is a generic per-connection seam that equally serves loading extensions or custom per-connection pragmas. If the hook returns Err, the connection is rejected — this surfaces as a pool/build error at store construction (e.g. SqliteStore::with_config fails outright) rather than on a later query, which matters for a wrong-key error: it should fail fast, not after migrations already ran against an unreadable database.
The hook must be idempotent per connection and cheap: r2d2 calls it once for every connection it opens, including replacements after errors. VACUUM INTO snapshots run on a pooled (already-keyed) connection, so backups of an encrypted database stay encrypted with no extra work.

Usage Example

CacheStore Trait

The CacheStore trait enables pluggable cache backends for the client’s data caches. By default, caches use the in-process PortableCache; implementing this trait lets you use Redis, Memcached, or any other external cache. Location: wacore/src/store/cache.rs

Namespaces

Each logical cache uses a unique namespace string. Implementations should partition keys by namespace (e.g., prefix as {namespace}:{key} in Redis).

Error handling

Cache operations are best-effort. The client treats read failures as cache misses and logs warnings on write failures. Implementations should still return errors for observability.

CacheStores configuration

Set individual caches or use CacheStores::all(store) to route all pluggable caches to the same backend:
See Custom backends — cache store for a full implementation example.

TypedCache

TypedCache<K, V> is a generic wrapper that dispatches to either the in-process PortableCache or a custom CacheStore backend. Location: src/cache_store.rs
invalidate_all() on custom CacheStore backends requires the tokio-runtime feature. Without it, the clear is silently skipped. Use the async clear() method as an alternative.

Implementing custom storage

To implement a custom storage backend:
  1. Implement all four domain traits
  2. The Backend trait is automatically implemented
  3. All methods must be async and thread-safe (Send + Sync)

Example: Redis store

Best Practices

  1. Thread Safety - Use Arc for shared state, Mutex for mutable state
  2. Error Handling - Convert backend errors to StoreError variants
  3. Transactions - Use database transactions for atomic operations
  4. Retries - Implement retry logic for transient failures
  5. Connection Pooling - Reuse connections when possible
  6. Blocking Operations - Wrap blocking I/O in tokio::task::spawn_blocking

Data Structures

AppStateSyncKey

LidPnMappingEntry

TcTokenEntry

An entry with an empty token and only sender_timestamp set is a byte-less placeholder written by touch_tc_token_sender_timestamp — it records that a post-send issuance IQ succeeded before any real token had been received from the contact.

DeviceListRecord

The raw_id field stores the ADV (Account Device Verification) key index list raw_id from device notifications. When this value changes for a user, it indicates an identity change (e.g., the user reinstalled WhatsApp). The client uses this to detect identity changes and clear Signal sessions for that user’s non-primary devices. Per-device sender key tracking is not wiped globally on identity change — that would empty the tracker too aggressively and feed the no-distribution path on the next group send. SKDM redistribution is instead driven per-group/per-device by retry receipts (matching WhatsApp Web’s WAWebUpdateLocalSignalSession/markForgetSenderKey behavior).
Breaking change: DeviceInfo gained the is_hosted field (marks whether the device belongs to WhatsApp’s hosted PN/LID address space, populated from usync device-list results). This breaks both construction and exhaustive pattern matching. Struct-literal construction (DeviceInfo { device_id, key_index }) no longer compiles — use DeviceInfo::new(device_id, key_index).with_hosting(is_hosted) instead. An exhaustive destructuring pattern (let DeviceInfo { device_id, key_index } = info;) also no longer compiles — add a .. to the pattern or match on is_hosted as well. Persisted JSON without is_hosted still deserializes correctly (it defaults to false); only Rust construction and pattern-matching call sites are affected. See USync for how is_hosted is used with Jid::with_device_hosting.

Error Handling

All storage operations return Result<T> from wacore::store::error. Each variant preserves the underlying typed error as its source() so callers can downcast to the original backend error when needed:
StoreError exposes a helper is_database_busy_or_locked() that walks the source chain looking for SQLite BUSY/LOCKED markers. Retry layers use it to decide whether a database error is transient without depending on a specific backend crate.

See also