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.
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.
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.
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.
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 versionstabs.sendMessagefrom DevTools pages. - Firefox: the relay pattern works; DevTools pages have
runtime.connectandinspectedWindow.eval;tabsAPIs are limited in DevTools contexts, which makes the relay necessary. - Safari: Web Inspector extensions support
devtools.inspectedWindowand runtime messaging; test the relay on your target version.
Verification
- Open DevTools and the panel; confirm state updates arrive.
- Stop the service worker in
chrome://serviceworker-internalsand confirm updates resume within a second. - Open DevTools on two tabs and confirm each panel shows only its tab.
- 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.
Related
- Building a custom DevTools panel — panel setup.
- Inspecting page state from a DevTools extension — reading state.
- Streaming progress updates over a port — port patterns.
- Side panel and DevTools interfaces — the parent topic.