The popup and the background service worker are completely separate processes in Manifest V3. When your popup button click needs to trigger a fetch, update a badge, or read persisted state that only the background context owns, it must ask via chrome.runtime.sendMessage. This guide is part of the message passing architecture overview and shows the full wiring — listener registration, typed payload dispatch, async response handling, state hydration on worker wake — with working code for each step.
The fundamental constraint: the service worker may be asleep when the popup opens. Chrome wakes it automatically on sendMessage, but the worker starts from scratch. Every piece of runtime state from the previous wake is gone. If your handler previously stored something in a module-level variable, it is no longer there. The fix is checkpointing to chrome.storage.local before yielding, so the woken worker can hydrate from storage rather than from memory.
Root cause — why naive implementations break
The most common failure looks like this: the popup calls sendMessage, the service worker receives the message, kicks off an async operation, and the response never arrives. The browser console shows undefined on the popup side with no thrown error.
Popup Service Worker
| |
sendMessage ──▶ onMessage fires
|
(async work starts)
|
return undefined ←─ listener forgets return true
|
channel torn down
|
◀── undefined | sendResponse() called after teardown → no-op
The root cause is always the same: the listener did not return true before yielding to the event loop. Chrome interprets a falsy return as “I am done, close the channel.” sendResponse then silently becomes a no-op. The fix is mechanical — always return true when your handler does any async work — but it must be done synchronously, before the first await.
Step 1 — Register the listener at the top level of the service worker
The listener must be registered in the synchronous top-level scope of service-worker.js. Anything inside a then, an await, or a chrome.runtime.onInstalled callback will not be registered on cold start and will silently miss messages.
1// service-worker.js
2import { handleGetStatus, handleSetFlag } from "./handlers.js";
3
4// Top-level: registered synchronously before any async code runs
5chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
6 // Always validate sender to reject spoofed messages
7 if (sender.id !== chrome.runtime.id) {
8 console.warn("Rejected message from unexpected sender:", sender.id);
9 return; // falsy return: channel closed, no response sent
10 }
11
12 if (message.type === "GET_STATUS") {
13 handleGetStatus(message, sendResponse);
14 return true; // async: keep channel open
15 }
16
17 if (message.type === "SET_FLAG") {
18 handleSetFlag(message, sendResponse);
19 return true;
20 }
21
22 // Unknown type: respond immediately so the popup does not hang
23 sendResponse({ ok: false, error: "Unknown message type" });
24});
Execution context: Service worker background thread. chrome.runtime, chrome.storage, and fetch are available. window, document, and localStorage are not. Firefox uses browser.runtime.onMessage but the chrome.* alias works in Firefox MV3 as well. Safari requires return true the same as Chrome.
Step 2 — Write the async handlers with state hydration
Each handler is a plain async function. It reads from chrome.storage.local to hydrate state that would otherwise have been lost during the last worker sleep, does its work, and calls sendResponse when ready.
1// handlers.ts
2export function handleGetStatus(
3 message: { type: "GET_STATUS" },
4 sendResponse: (r: unknown) => void
5) {
6 (async () => {
7 // Hydrate from storage — never rely on module-scope variables for state
8 const { appState, lastSyncAt } = await chrome.storage.local.get([
9 "appState",
10 "lastSyncAt",
11 ]);
12
13 sendResponse({
14 ok: true,
15 state: appState ?? "idle",
16 lastSyncAt: lastSyncAt ?? null,
17 });
18 })();
19 // Note: no return here — the caller (onMessage listener) returns true
20}
21
22export function handleSetFlag(
23 message: { type: "SET_FLAG"; flag: string; value: boolean },
24 sendResponse: (r: unknown) => void
25) {
26 (async () => {
27 await chrome.storage.local.set({ [message.flag]: message.value });
28 sendResponse({ ok: true });
29 })();
30}
Execution context: Service worker. The IIFE pattern ((async () => { ... })()) is the standard way to start async work inside a synchronous listener. The listener itself returns true (in Step 1) before the IIFE resolves. Avoid top-level await in the listener body — it would defer the return true and close the channel before any async work runs.
The popup dispatches messages with a typed payload object and awaits the response. Wrap every call in a try/catch: sendMessage rejects if the service worker has not yet registered its listener (very early on first install) or if the extension is reloading.
1// popup.ts
2interface StatusResponse {
3 ok: boolean;
4 state: string;
5 lastSyncAt: number | null;
6 error?: string;
7}
8
9async function fetchStatus(): Promise<StatusResponse | null> {
10 try {
11 const response: StatusResponse = await chrome.runtime.sendMessage({
12 type: "GET_STATUS",
13 });
14 return response ?? null;
15 } catch (err) {
16 const msg = (err as Error).message;
17 if (msg.includes("Could not establish connection")) {
18 // Worker not yet registered — safe to ignore on first popup open
19 console.warn("Service worker not ready yet:", msg);
20 return null;
21 }
22 throw err; // Re-throw unexpected errors
23 }
24}
25
26document.addEventListener("DOMContentLoaded", async () => {
27 const status = await fetchStatus();
28 if (status?.ok) {
29 document.getElementById("status")!.textContent = status.state;
30 }
31});
Execution context: Extension popup page (UI thread / renderer process). The popup has a separate JavaScript environment from the service worker; no shared globals or module caches exist between them. Chrome automatically wakes the service worker before delivering sendMessage if it is currently sleeping.
Step 4 — Persist state before yielding to prevent data loss
If your handler fetches remote data and needs to cache it, write to storage before doing any further async work. This ensures a worker that is killed mid-handler leaves a consistent checkpoint that the next wake can resume from.
1// handlers.ts — safe remote fetch with checkpoint
2export function handleSyncRemoteData(
3 message: { type: "SYNC_REMOTE"; endpoint: string },
4 sendResponse: (r: unknown) => void
5) {
6 (async () => {
7 try {
8 const res = await fetch(message.endpoint);
9 const data = await res.json();
10
11 // Checkpoint IMMEDIATELY after fetch — before further processing
12 await chrome.storage.local.set({
13 cachedData: data,
14 lastSyncAt: Date.now(),
15 });
16
17 // Now do further work knowing the data is safely persisted
18 const processed = transform(data);
19 sendResponse({ ok: true, result: processed });
20 } catch (err) {
21 sendResponse({ ok: false, error: (err as Error).message });
22 }
23 })();
24}
Execution context: Service worker. fetch is available in MV3 service workers on Chrome, Firefox, and Safari. Safari requires the fetch call to be made within an active request context on some older versions; test on Safari 17.4+ for reliable behaviour. The chrome.storage.local write is the durability checkpoint — if the worker is killed after the await set(...) line, the data is already persisted and the next wake can serve it from cache.
On first install or during an extension reload, the popup may open before the service worker has finished registering its listeners. A simple retry loop handles this edge case without polling aggressively.
1// popup.ts — retry helper
2async function sendWithRetry<T>(
3 message: Record<string, unknown>,
4 maxAttempts = 3,
5 baseDelayMs = 200
6): Promise<T | null> {
7 for (let attempt = 0; attempt < maxAttempts; attempt++) {
8 try {
9 return await chrome.runtime.sendMessage(message) as T;
10 } catch (err) {
11 const isConnectionError =
12 (err as Error).message.includes("Could not establish connection");
13 if (!isConnectionError || attempt === maxAttempts - 1) throw err;
14 await new Promise((r) => setTimeout(r, baseDelayMs * 2 ** attempt));
15 }
16 }
17 return null;
18}
Execution context: Extension popup page. setTimeout works normally in extension pages (unlike in service workers where it is cleared on sleep). The exponential backoff — 200 ms, 400 ms, 800 ms — is short enough to be invisible to users on first open but long enough to let the worker finish cold-start initialization.
Cross-browser variation
- Chrome / Edge:
sendMessage returns a Promise in MV3. The sendResponse callback and return true are required for async handlers. chrome.runtime.lastError is set if the channel closes unexpectedly — check it to suppress false-positive console errors. - Firefox MV3: Uses
browser.runtime natively. A listener can return a Promise directly instead of calling sendResponse + return true, which is cleaner. However, returning true and calling sendResponse explicitly still works and keeps the code Chrome-compatible without a polyfill. - Safari (WebKit 17.4+): Requires
return true for async handlers — identical to Chrome. Top-level await in service worker modules is not supported on some versions; use the IIFE pattern shown in Steps 2 and 4 to avoid it.
Verification
Open chrome://extensions, enable Developer mode, and click “Service worker” next to your extension to open its DevTools panel.
- Set a breakpoint on the
onMessage.addListener callback registration line. Reload the extension. The breakpoint should fire immediately on cold start — if it only fires after a delay, the listener is nested inside an async callback and will miss early messages. - In the popup DevTools console, run
chrome.runtime.sendMessage({ type: "GET_STATUS" }). The Promise should resolve to { ok: true, state: ..., lastSyncAt: ... }. - In the service worker DevTools console, check
chrome.storage.local.get(null) after a SYNC_REMOTE call. The cachedData and lastSyncAt keys should be present, confirming the checkpoint write landed before the handler returned. - Force-terminate the service worker by clicking “terminate” in the service worker DevTools panel, then re-send
GET_STATUS from the popup. The response should still arrive with correctly hydrated state from storage.
FAQ
Why does sendMessage sometimes return undefined even when sendResponse is called?
The most common cause is that return true was omitted from the listener. Chrome tears down the channel at the end of the synchronous listener tick. When sendResponse fires afterward, it is a no-op and the promise on the caller side resolves to undefined without throwing. The fix is in Step 1: return true before any await.
Not with chrome.runtime.sendMessage alone — that always routes to the background. To message a specific content script, use chrome.tabs.sendMessage(tabId, message) with the target tab’s ID. The service worker typically acts as the router: popup → service worker → content script via tabs.sendMessage.
What happens if the service worker is killed while sendResponse is pending?
The channel closes and the popup’s sendMessage promise rejects with "The message port closed before a response was received". Catch that error in the popup and retry or show a user-facing error. For long-running operations, checkpoint progress to storage so a fresh worker wake can resume rather than restart.
Should I use one message type string or separate message objects for different actions?
A single type discriminant field on a shared interface is the most maintainable pattern. It lets you use TypeScript discriminated unions for exhaustive type narrowing in the handler switch and avoids the proliferation of separate addListener calls for each action.