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.

Published October 2, 2026 Updated October 2, 2026 7 min read
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.

A traced request across three contextsThe popup sends get-status with trace id 7f3a; the worker logs receipt, forwards to the content script with the same id, which logs and replies; the worker logs the reply and responds to the popup; all five events land in one trace buffer with timings.PopupService workerContent scriptsend get-status #7f3aforward #7f3areply #7f3a (12 ms)reply #7f3a (19 ms total)
One ID follows the message through every hop.

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.

Message failures and what the trace showsCommon messaging failures — no receiver, port closed before response, wrong listener responding, slow handler, message never sent — and the pattern each produces in the trace.SymptomTrace showsUsual causeReceiving end does not existsend → error, no recvNo content script / page closedPort closed before responserecv, no replyAsync handler without return trueUnexpected responseTwo recv, first repliesAnother listener answeredSpinner foreversend, recv, long gapSlow or hung handlerNothing happensNo sendCode path never ran
Each failure has a recognisable trace signature.

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.

Trace collectionPopup, side panel and content scripts send trace entries to the service worker, which keeps a 500-entry ring buffer; the developer calls dumpTrace in the worker console to read all contexts' events in order, optionally filtered by trace id.Popupsend / replySide panelsend / recvContent scriptsrecv / respond__trace entriesWorker ring bufferlast 500dumpTrace(id?)console.tablestorage.session mirroroptional
Scattered consoles become one ordered log.

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.sendMessage returns a promise; returning a promise from a listener is supported in recent versions, return true + sendResponse everywhere.
  • Firefox: browser.runtime.onMessage listeners 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

  1. Enable tracing, perform an action, and confirm dumpTrace() shows send, recv, respond and reply with one ID.
  2. Message a tab without a content script and confirm an error entry with the reason.
  3. Remove return true from a test handler and confirm the trace shows recv without respond.
  4. Disable tracing and confirm no __trace messages 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.

Other Testing, Debugging & Performance Optimization Resources