Connecting a DevTools Panel to the Service Worker

Wire a DevTools panel to an MV3 service worker and the inspected page's content script: long-lived ports keyed by inspectedWindow.tabId, relaying content script events, reconnecting after worker restarts, and cleanup when DevTools closes.

Published October 2, 2026 Updated October 2, 2026 7 min read
Table of Contents

A state-inspector DevTools extension needs three parts to cooperate: a content script that observes the page’s application state, a DevTools panel that displays it, and a service worker in the middle. The panel cannot message the content script with chrome.tabs.sendMessage in every browser, content scripts cannot address the panel directly, and the service worker may stop at any moment, dropping connections. The established pattern — panel and content script both connect to the worker with ports, and the worker relays by tab ID — works everywhere, but it has to handle the worker restarting and DevTools closing. This guide builds it. It belongs to side panel and DevTools interfaces.

Why relay through the service worker

DevTools pages and panels have access to a limited set of extension APIs: devtools.*, runtime messaging and ports, and in Chrome a subset of others. The content script in the inspected tab knows nothing about the panel; runtime.sendMessage from the content script reaches every extension page, but the panel would need to filter by tab and every DevTools window would receive every message. A port from the panel to the worker, tagged with chrome.devtools.inspectedWindow.tabId, lets the worker keep a map from tab ID to panel port; the content script’s messages arrive at the worker with sender.tab.id, and the worker forwards them to the matching panel only. Commands flow the other way with tabs.sendMessage.

Relay topologyThe DevTools panel connects a port to the service worker and sends init with the inspected tab id; the worker stores panels by tab id; the content script sends state updates which the worker forwards to the matching panel; panel commands go to the worker which forwards them with tabs.sendMessage.DevTools panelport 'devtools'Service workerpanels: Map<tabId, port>Content scriptinspected tabinit {tabId}on connectforward by sender.tab.idCS → paneltabs.sendMessagepanel → CS
The worker maps tab id → panel port and relays both ways.

Step-by-step: a robust relay

1. Connect from the panel with the inspected tab ID

 1// panel.js
 2const tabId = chrome.devtools.inspectedWindow.tabId;
 3let port;
 4
 5function connect() {
 6  port = chrome.runtime.connect({ name: "devtools" });
 7  port.postMessage({ type: "init", tabId });
 8  port.onMessage.addListener(handleFromWorker);
 9  port.onDisconnect.addListener(() => { port = null; setTimeout(connect, 250); });   // worker restarted — reconnect
10}
11connect();
12
13function send(msg) { (port ?? (connect(), port)).postMessage(msg); }

Execution context: the DevTools panel page. inspectedWindow.tabId identifies the tab this DevTools window is attached to. The panel’s first message registers it. When the service worker stops, all its ports disconnect; the panel reconnects (starting a new worker) and re-registers, so the relay heals itself. A small delay avoids a tight loop if something is persistently wrong.

2. Keep the panel map in the worker

 1// sw.js
 2const panels = new Map();   // tabId → port
 3
 4chrome.runtime.onConnect.addListener((port) => {
 5  if (port.name !== "devtools") return;
 6  let tabId;
 7  port.onMessage.addListener((msg) => {
 8    if (msg.type === "init") { tabId = msg.tabId; panels.set(tabId, port); return; }
 9    if (tabId != null) chrome.tabs.sendMessage(tabId, msg).catch(() => port.postMessage({ type: "no-content-script" }));
10  });
11  port.onDisconnect.addListener(() => { if (panels.get(tabId) === port) panels.delete(tabId); });
12});

Execution context: the service worker, listeners at the top level. The map is in memory, which is fine: it only needs to exist while ports are connected, and ports reconnect after a restart. Commands from the panel are forwarded to the content script; if none is present, the panel is told so it can offer to inject. Removing the entry on disconnect only if it still points to this port avoids a race when a panel reconnects quickly.

Healing after a service worker restartThe worker is stopped, disconnecting the panel's port; the panel reconnects after a short delay, which starts a new worker, and re-sends init with its tab id; the content script's next update is forwarded to the panel again.PanelService workerContent scriptworker stops → onDisconnectconnect() → new workerinit {tabId: 42}state-update (tab 42)forward
Reconnect + re-init makes the relay survive worker restarts.

3. Forward content script events to the right panel

 1// sw.js
 2chrome.runtime.onMessage.addListener((msg, sender) => {
 3  if (msg.channel !== "inspector" || !sender.tab) return;
 4  panels.get(sender.tab.id)?.postMessage({ ...msg, frameId: sender.frameId });
 5});
 6
 7// content script
 8function publish(state) {
 9  chrome.runtime.sendMessage({ channel: "inspector", type: "state-update", state }).catch(() => {});
10}

Execution context: the service worker and content script. Only tabs with an open panel get forwarded messages; others are dropped. A dedicated channel field keeps inspector traffic separate from other extension messaging. Including frameId lets the panel distinguish iframes. The content script catches the rejection when no listener responds.

Panel communication optionsRelaying via the service worker, devtools.inspectedWindow.eval, and direct tabs.sendMessage from the panel compared on access to page state, cross-browser support and complexity.ApproachReachesCross-browserComplexityPort relay via workerContent script (isolated)Chrome, FirefoxMediuminspectedWindow.evalPage main worldChrome, FirefoxLowtabs.sendMessage from panelContent scriptChrome (recent)Low
The relay is the portable default; eval is for quick reads of page globals.

4. Read page globals with inspectedWindow.eval when appropriate

1chrome.devtools.inspectedWindow.eval("window.__APP_STATE__ && JSON.stringify(window.__APP_STATE__)", (result, err) => {
2  if (err?.isException) return showError(err.value);
3  renderState(result ? JSON.parse(result) : null);
4});

Execution context: the DevTools panel. eval runs in the page’s main world — where the app’s globals live, unlike the content script’s isolated world — and returns JSON-serialisable results. It is ideal for on-demand reads. For continuous updates, the content script approach is better; to observe main-world state continuously, inject a main-world script that posts to the content script via window.postMessage. See inspecting page state from a DevTools extension.

5. Handle navigation and reinjection

1// panel.js
2chrome.devtools.network.onNavigated.addListener(() => {
3  clearView();
4  send({ type: "subscribe" });       // the new page's content script needs to start publishing
5});
6function handleFromWorker(msg) {
7  if (msg.type === "no-content-script") showInjectButton();
8  if (msg.type === "state-update") renderState(msg.state);
9}

Execution context: the DevTools panel. A navigation replaces the content script; re-subscribing tells the new one to start publishing. If the content script is not declared statically, the panel can ask the worker to inject it with chrome.scripting.executeScript — the worker needs host permissions for that.

6. Stop work when DevTools closes

1// content script
2let subscribers = 0;
3chrome.runtime.onMessage.addListener((msg) => {
4  if (msg.type === "subscribe") { subscribers = 1; startObserving(); }
5  if (msg.type === "unsubscribe") { subscribers = 0; stopObserving(); }
6});
7
8// sw.js — inside port.onDisconnect
9chrome.tabs.sendMessage(tabId, { type: "unsubscribe" }).catch(() => {});

Execution context: the content script and worker. When DevTools closes, the panel’s port disconnects without reconnecting (the panel page is gone). The worker tells the content script to stop observing, so pages are not slowed by an inspector nobody is looking at. Because a disconnect also happens on worker restarts, the reconnecting panel’s subscribe restores observation.

7. Keep messages small

Send diffs or summaries rather than the full state on every change; throttle updates to a few per second. Structured cloning large objects through two hops (content script → worker → panel) is the main performance cost of this pattern.

Common mistakes

  • No reconnect on disconnect. The panel goes silent after the worker restarts.
  • Broadcasting to all panels. Every DevTools window receives every tab’s data.
  • Forgetting inspectedWindow.tabId. The worker can’t route.
  • Observing forever. Pages stay slow after DevTools closes.
  • Full state on every change. Two-hop cloning is expensive.

Cross-browser variation

  • Chrome / Edge: port relay, inspectedWindow.eval, and in recent versions tabs.sendMessage from DevTools pages.
  • Firefox: the relay pattern works; DevTools pages have runtime.connect and inspectedWindow.eval; tabs APIs are limited in DevTools contexts, which makes the relay necessary.
  • Safari: Web Inspector extensions support devtools.inspectedWindow and runtime messaging; test the relay on your target version.

Verification

  1. Open DevTools and the panel; confirm state updates arrive.
  2. Stop the service worker in chrome://serviceworker-internals and confirm updates resume within a second.
  3. Open DevTools on two tabs and confirm each panel shows only its tab.
  4. Close DevTools and confirm the content script stops observing.

FAQ

Can the panel call chrome.storage?

In Chrome, yes; in other browsers DevTools pages have fewer APIs, so route storage through the worker for portability.

Why not keep the panel map in storage.session?

Ports cannot be stored; the map only matters while ports are live, and they re-register on reconnect.

Does the DevTools page or the panel own the port?

Either. Use the panel if only it needs data; use the DevTools page if data should be captured before the panel is opened.

Will the reconnect loop keep the worker alive forever?

No. Ports do not extend the service worker’s lifetime indefinitely in current Chrome; the worker still stops when idle, and the panel reconnects when it next needs it. The short delay prevents a busy loop.

Other UI/UX Patterns & Interactive Components Resources