"Storage in Lesson 4 was the passive memory the worker writes to. Messages are the active wake-up signal between contexts. Lesson 5 is the rope between popup and service worker — short, taut, pulled exactly once per turn."
Two Halves of the Same Channel
Every Chrome extension exposes the same runtime channel in every context. To send: chrome.runtime.sendMessage. To receive: chrome.runtime.onMessage.addListener. There is no separate "popup messaging" or "SW messaging" API — both contexts use the same two halves.
sendMessage(message, callback?)— fires a message into the runtime. Returns a Promise in MV3 when no callback is supplied. Every other registered listener inside the extension receives it.onMessage.addListener((message, sender, sendResponse) => boolean | undefined)— registers a handler that runs for every incoming message. Returntrueif you intend to callsendResponseasynchronously; returnundefinedfor synchronous (or no) response.
The sender argument tells you where the message came from — popup, content script, side panel, options page. Useful for routing, and for refusing requests from contexts that shouldn't be initiating them.
The return true Invariant
If your listener does async work before responding — say, reading from chrome.storage — you MUST return true from the listener body, synchronously, before the await suspends:
The Chrome runtime keeps the message channel open only while the listener is on the stack. return true tells the runtime "I will call sendResponse later, keep the channel alive." Forget it, and sendResponse in the async path silently no-ops — the caller waits forever and times out around five minutes later. This is the single most common message-passing bug in MV3.
Two Directions, Two Patterns
Popup → SW. The popup is short-lived; it asks the SW to do something and waits for an answer. Common: "give me the current clip list," "save this clip," "delete this clip id." The SW does the storage read/write and responds.
SW → popup. Less common, because the popup may not be open. If you send a message and no popup is listening, the SDK returns an error along the lines of "Receiving end does not exist." For SW-to-popup updates, prefer chrome.storage.onChanged (Lesson 4) — the popup subscribes when it opens, the SW writes to storage, both contexts react. No "is the popup open" guard required.
Combining Messages with Storage
The pragmatic ClipDeck pattern that emerges:
- Actions flow through messages. "Save clip," "delete clip," "clear all." The popup sends, the SW mutates storage, then responds with the new state.
- State updates flow through storage. The popup mounts, reads storage once, then subscribes to
onChanged. Any future SW-side mutation triggers a re-render automatically.
That split is what keeps the codebase honest: messages are verbs, storage is the noun. Mixing them — sending the full state in messages, broadcasting changes via messages — works for tiny extensions but turns into spaghetti the moment clips start accumulating.
ClipDeck Preview: Popup Pings SW
The exercise below adds a "Ping SW" button to ClipDeck's popup. It sends { type: "ping" }, the SW responds with { ok: true, at: Date.now() }, and the popup shows the timestamp. Lesson 6 turns this scaffolding into a real visit-counter feature, and Track 3 starts using the same channel for ClipDeck's actual CRUD-C operation: "save this selected text as a clip."
storage.onChanged to see what happened.async work before calling sendResponse, return true synchronously. Forget it and the message channel closes the moment the listener returns. sendResponse in the async path becomes a no-op; the caller waits forever and eventually times out. Symptom: popup spinner that never resolves, no error in either DevTools console.