C.W.K.
Stream
Lesson 05 of 06 · published

Message Passing — The Short, Taut Rope Between Popup and SW

~12 min · messaging, runtime, sendMessage, onMessage, async, service-worker

Level 0Extension Curious
0 XP0/54 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"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. Return true if you intend to call sendResponse asynchronously; return undefined for 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."

Messages are verbs (actions). Storage is the noun (state). Send messages to ask the SW to do something; subscribe to storage.onChanged to see what happened.
The silent timeout trap. If your listener does 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.

Code

Synchronous round-trip — popup sends, SW responds in the same tick·javascript
// popup.js — send a ping and await the response
async function pingSw() {
  const response = await chrome.runtime.sendMessage({ type: "ping" });
  console.log("[ClipDeck popup] response:", response);
  return response;
}

// background.js — sync response, no `return true` needed
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message?.type === "ping") {
    sendResponse({ ok: true, at: Date.now() });
    return; // synchronous; channel can close immediately
  }
});
Async round-trip — read/write storage inside the listener, return true synchronously·javascript
// background.js — async response requires `return true`
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message?.type === "getClips") {
    (async () => {
      const { clips = [] } = await chrome.storage.local.get("clips");
      sendResponse({ ok: true, clips });
    })();
    return true; // keep the channel open for the async sendResponse
  }
  if (message?.type === "saveClip") {
    (async () => {
      const { clips = [] } = await chrome.storage.local.get("clips");
      const next = [...clips, message.payload];
      await chrome.storage.local.set({ clips: next });
      sendResponse({ ok: true, count: next.length });
    })();
    return true;
  }
});

External links

Exercise

In clipdeck/popup.html, add <button id="pingBtn">Ping SW</button> and <div id="pingResult"></div>. In clipdeck/popup.js, wire the button to call chrome.runtime.sendMessage({type:'ping'}) and write the JSON-stringified response into pingResult. In clipdeck/background.js, register an onMessage listener that responds to {type:'ping'} with {ok:true, at:Date.now()} synchronously. Reload the extension, open the popup, click Ping SW — the timestamp should land in the div. Then close the popup, wait about 35 seconds so the SW evicts, click the ClipDeck toolbar icon again, click Ping SW — does it still work? (It should — incoming messages are one of the wake-up triggers for an evicted SW.)
Hint
If the popup response comes back as undefined, check the popup's DevTools console for Receiving end does not exist. That usually means the SW had no listener registered when the message arrived — either the listener registration is buried inside an async function that hasn't yet run on cold start, or background.js itself failed to load (check the service-worker DevTools for syntax errors). Register onMessage at the top level of background.js, never inside an async function or another event handler. Also remember: sendResponse only works once per message; calling it twice is a silent no-op on the second call.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.