WhatsApp Web CRM Sidecar Architecture
Runtime store discovery, MV3 statelessness, and a read path that never disturbs the session
How a Manifest V3 extension reads WhatsApp Web's internal stores safely: two-tier module discovery, a fail-closed read path, and history paging that leaves the active chat untouched.
Named module id first, structural match as fallback
Discovery path
None in memory — re-read from storage per message
Service worker state
Companion write-up for WhatsApp Web CRM Sidecar.
Problem
Sync a rep’s live WhatsApp Web conversations into a CRM with no official API for it, against an application that ships new minified bundles on its own schedule, inside an extension runtime that can kill your process between any two events — and never interrupt the rep while doing it.
Three constraints, each pulling a different way:
- No stable interface. Every symbol the extension depends on is internal to WhatsApp Web.
- No durable process. Manifest V3 terminates the service worker whenever it likes.
- No visible footprint. A sync that steals the rep’s active chat is worse than no sync.
Discovery: two tiers, deliberately
Tier 1 is one call and works before login. Tier 2 is the dashed path: a degradation route kept only for the rebuild that renames a module id, ending in a fail-closed state rather than a guess.
Tier 2 came first historically and was demoted for a reason. Shape matching asks “does this object
have _models, modelClass, and notSpam?” — and more than one thing does. The Chat matcher
resolved the Newsletter collection; the Contact matcher resolved group metadata. The objects
were the right shape and the wrong object, which no amount of tightening the predicate fixes
reliably.
Tier 1 asks for the store by exact module id and gets it. Tier 2 stays in the codebase because the failure mode it covers is real: a WhatsApp rebuild that renames modules breaks tier 1 by definition, and a degraded-but-working path beats a hard stop.
A related bundler quirk cost time worth recording: after one WhatsApp build change, modules arrived
double-wrapped for ESM interop as mod.default.default rather than mod.default, silently breaking
matching. The resolution adds an unwrapped-shape candidate rather than picking which layer is
“correct” — the bundle gets to change its mind.
Identity resolution
WhatsApp’s @lid privacy identity means the number read directly off a chat record is sometimes not
a phone number. Resolution goes through the Contact store rather than trusting the chat, so a
CRM lead is never created against a synthetic identifier.
Call history: the hypothesis that was wrong
The intuitive answer, WAWebCallCollection, is wrong in a way that only shows up after a call ends:
it tracks the in-progress call and its history array empties immediately. The Calls tab is actually
rendering ordinary chat messages with type === "call_log", which are already in memory because the
chat list needs them for preview text — so call history is available before the Calls tab is ever
opened, from a store that was already loaded. Neither whatsapp-web.js nor open-wa implement this.
The read path that stays out of the way
The earlier catch-up implementation opened a chat in the UI to sync it, which visibly switched the
rep’s active chat mid-sync. WAWebChatLoadMessages.loadEarlierMsgs() pages history directly against
the store instead, with no UI interaction at all — verified live by comparing
Store.Chat.getActive() before and after a full history page and finding it identical.
That verification is the load-bearing part. “Should not affect the UI” is an assumption; “the active chat object is unchanged across a full page of history” is a check. Background catch-up now runs periodically for anyone who consented once, and still skips a chat while the rep is actively typing in it.
MV3 constraints as architecture
- No in-memory state in the service worker. Chrome terminates it between events, so anything
durable goes through
chrome.storage.localand is re-read per message. State that lives in a variable is state that disappears. - One
chrome.runtime.onMessagedispatcher keyed bymessage.typeis the only cross-context entry point. The CRM auth key never leaves the background context, so a compromised or merely buggy content script onweb.whatsapp.comcannot read it. - Two features are deliberately backendless. Follow-up reminders and the do-not-contact list run
on
chrome.alarms,chrome.notifications, and local storage only — shipped without waiting on backend capacity, and documented as that choice rather than pretending it was the ideal design.
Failure policy
Every read of WhatsApp internals is one deploy away from being wrong, so the policy is fail-closed: if the chat-list ordering assumption breaks, the sync indicator renders nothing. No badge is safer than a wrong badge — a missing indicator is a support ticket, an indicator on the wrong contact is a data-integrity incident.
What this cost, honestly
Two tiers of discovery, an unwrapped-shape candidate, an identity indirection, and a fallback path that is dead code on a good day. All of it is overhead the official API would have made unnecessary — and none of it is removable while the requirement is reading a live personal session. The design does not pretend the constraint away; it makes the breakage survivable and visible.