Vishal Tyagi
← Return to Writing
Case Study·production

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.

Date Shipped2026-08
Estimated Reading4 min read
Lifecycle Statusproduction
Primary Tech Stack
JavaScript, Chrome Extensions (MV3), Web Reverse Engineering

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:

  1. No stable interface. Every symbol the extension depends on is internal to WhatsApp Web.
  2. No durable process. Manifest V3 terminates the service worker whenever it likes.
  3. No visible footprint. A sync that steals the rep’s active chat is worse than no sync.

Discovery: two tiers, deliberately

Diagram
resolvedid renamedmatchedno matchresolveStore(name)Tier 1exact module idStore handleTier 2shape scanFail closedno indicator

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.local and is re-read per message. State that lives in a variable is state that disappears.
  • One chrome.runtime.onMessage dispatcher keyed by message.type is the only cross-context entry point. The CRM auth key never leaves the background context, so a compromised or merely buggy content script on web.whatsapp.com cannot 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.