Tracing Messages Between Contexts
Trace runtime and tabs messages across an MV3 extension's service worker, popup, side panel and content scripts: a message envelope with IDs, logging wrappers, a central trace buffer, timing, finding lost responses, and turning tracing off in production.
Table of Contents
The popup sends get-status to the service worker, which asks the content script, which answers — usually. Sometimes the popup spins forever. Is the worker not receiving it, not forwarding it, is the content script missing, or did someone forget return true for an async response? Each context has its own console, so the evidence is scattered across three DevTools windows that may have already closed. Message flows are the nervous system of an extension, and debugging them without tracing is guesswork. This guide adds lightweight tracing: an envelope with IDs and timestamps, wrappers that log every send and receive, and one buffer where the whole conversation can be read in order. It belongs to debugging extension contexts.
What makes messages hard to follow
chrome.runtime.sendMessage broadcasts to every extension page and the service worker; chrome.tabs.sendMessage targets one tab’s content scripts. A message can be received by several listeners, but only the first to respond wins; a listener that responds asynchronously must return true (or a promise, in newer Chrome) or the channel closes with “The message port closed before a response was received”. If the receiving context does not exist, the sender gets “Could not establish connection. Receiving end does not exist.” Logs from each side land in different consoles, often in contexts that close. Tracing fixes this by giving every message an ID, logging both ends with the same ID, and collecting the logs in one place that persists.
Step-by-step: message tracing
1. Wrap messages in an envelope
1// messaging.js — shared by all contexts
2export const CTX = globalThis.ServiceWorkerGlobalScope ? "sw"
3 : location.protocol === "chrome-extension:" ? location.pathname.replace(/^\/|\.html$/g, "")
4 : "cs";
5
6export function envelope(type, payload, parent) {
7 return { type, payload, trace: { id: parent?.id ?? crypto.randomUUID().slice(0, 8), hop: (parent?.hop ?? 0) + 1, from: CTX, t: Date.now() } };
8}
Execution context: every extension context. The trace field travels with the message; a forwarded message keeps the original ID and increments hop, so a multi-hop conversation shares one ID. CTX names the sender — sw, popup, sidepanel, or cs for content scripts.
2. Log sends and replies with a wrapper
1export async function send(type, payload, { tabId, parent } = {}) {
2 const msg = envelope(type, payload, parent);
3 trace("send", msg);
4 try {
5 const res = tabId != null ? await chrome.tabs.sendMessage(tabId, msg) : await chrome.runtime.sendMessage(msg);
6 trace("reply", msg, { ms: Date.now() - msg.trace.t, ok: true });
7 return res;
8 } catch (e) {
9 trace("error", msg, { ms: Date.now() - msg.trace.t, error: e.message });
10 throw e;
11 }
12}
Execution context: every extension context. All application code sends through send, so every message is traced without extra effort. Errors such as “Receiving end does not exist” are recorded with the message they belong to, which is usually all you need to diagnose a missing content script.
3. Log receipt in a listener wrapper
1export function onMessage(handlers) {
2 chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
3 const h = handlers[msg?.type];
4 if (!h) return; // not ours — don't respond
5 trace("recv", msg, { sender: sender.tab ? `tab ${sender.tab.id}/frame ${sender.frameId}` : sender.url?.split("/").pop() });
6 Promise.resolve()
7 .then(() => h(msg.payload, sender, msg.trace))
8 .then((result) => { trace("respond", msg); sendResponse(result); })
9 .catch((e) => { trace("handler-error", msg, { error: e.message }); sendResponse({ error: e.message }); });
10 return true; // keep the channel open for async handlers
11 });
12}
Execution context: every receiving context. The wrapper only claims messages it has a handler for, avoiding the “wrong listener answered” problem, and always returns true so async handlers work. Handler errors become structured error responses instead of silent channel closures. See handling errors across message boundaries.
4. Collect traces in one buffer
1const DEBUG = await chrome.storage.local.get("debugTrace").then((r) => Boolean(r.debugTrace)).catch(() => false);
2
3export function trace(event, msg, extra = {}) {
4 if (!DEBUG) return;
5 const entry = { event, ctx: CTX, id: msg.trace.id, hop: msg.trace.hop, type: msg.type, at: Date.now(), ...extra };
6 console.debug(`[trace ${entry.id}] ${CTX} ${event} ${msg.type}`, extra);
7 if (CTX === "sw") return pushTrace(entry);
8 chrome.runtime.sendMessage({ type: "__trace", entry }).catch(() => {});
9}
10
11// sw.js
12const ring = [];
13export function pushTrace(e) { ring.push(e); if (ring.length > 500) ring.shift(); }
14chrome.runtime.onMessage.addListener((m) => { if (m.type === "__trace") pushTrace(m.entry); });
15globalThis.dumpTrace = (id) => console.table(ring.filter((e) => !id || e.id === id));
Execution context: every context, with the buffer in the service worker. Each context logs to its own console and also ships entries to the worker’s ring buffer. In the worker console, dumpTrace() shows the last 500 events across all contexts in order, and dumpTrace("7f3a") shows one conversation. Trace messages themselves are not traced, to avoid recursion. The buffer is in memory, which is fine for live debugging; for intermittent issues, mirror it to storage.session.
5. Turn tracing on and off at runtime
1// Worker console
2await chrome.storage.local.set({ debugTrace: true }); chrome.runtime.reload();
Execution context: the service worker console, or a hidden toggle on the options page. Reading the flag at startup keeps tracing cost at zero when disabled. For a user reporting a bug, an options-page “Enable diagnostics” switch plus “Copy diagnostics” (the ring buffer as JSON, with payloads stripped) gives you the trace without remote logging.
6. Measure timing and spot slow handlers
reply entries carry ms. Sort the buffer by ms to find slow round trips, and compare recv and respond timestamps on the receiving side to separate handler time from transport. Handlers that take hundreds of milliseconds usually wait on storage or network and may benefit from caching. See measuring storage read and write latency.
7. Keep payloads out of traces
Trace entries record types, IDs, timings and errors — not payloads, which may contain page content or user data. If you need payloads while debugging locally, log them to the local console only, never to persisted or exported traces.
Common mistakes
- No IDs. Logs from different contexts can’t be correlated.
- Listeners that respond to every message. The wrong one answers first.
- Async handlers without
return true. The port closes before the response. - Tracing in production by default. Overhead and privacy risk; gate it.
- Tracing the trace messages. Infinite recursion.
Cross-browser variation
- Chrome / Edge:
runtime.sendMessagereturns a promise; returning a promise from a listener is supported in recent versions,return true+sendResponseeverywhere. - Firefox:
browser.runtime.onMessagelisteners can return a promise directly; the same envelope works. - Safari: messaging matches the WebExtensions model; tracing via the background page console works the same way.
Verification
- Enable tracing, perform an action, and confirm
dumpTrace()shows send, recv, respond and reply with one ID. - Message a tab without a content script and confirm an
errorentry with the reason. - Remove
return truefrom a test handler and confirm the trace shows recv without respond. - Disable tracing and confirm no
__tracemessages are sent.
FAQ
Can I see messages in DevTools without code changes?
Not as a stream. Chrome has no built-in extension message inspector; wrappers are the practical approach.
Does tracing affect timing?
Slightly. Disable it for performance measurements.
Should ports be traced too?
Yes — wrap postMessage and onMessage on ports with the same envelope. See streaming progress updates over a port.
What about messages from web pages?
Messages from pages via externally_connectable or window.postMessage should be traced at the boundary with their origin recorded, since they come from untrusted senders and are a common source of surprises.
Related
- Logging across contexts without losing messages — general logging.
- Handling errors across message boundaries — structured errors.
- Typed message contracts with TypeScript — fewer message bugs.
- Debugging extension contexts — the parent topic.