Passing Data Between Side Panel and Content Script
Connect an MV3 side panel to the content script in the active tab: tabs.sendMessage from the panel, long-lived ports, tracking the active tab as it changes, relaying through the service worker, and handling pages without a content script.
Table of Contents
A note-taking side panel should show the selected text from the page, jump to a heading when the user clicks it in the panel’s outline, and update when the user switches tabs. The panel and the page live in different worlds: the side panel is an extension page that persists across tab switches, and the content script runs inside one tab’s page. They can talk directly — the panel can call chrome.tabs.sendMessage like any extension page — but only if the panel always knows which tab is active, the content script is actually there, and both sides handle the other going away. This guide wires that connection reliably. It belongs to side panel and DevTools interfaces.
The two directions
Panel → page: the side panel is an extension context with full chrome.tabs access, so it can call chrome.tabs.sendMessage(tabId, msg) to the content script in the active tab, or chrome.scripting.executeScript to run a function there. Page → panel: the content script calls chrome.runtime.sendMessage, which is delivered to all extension pages, including the side panel, and the service worker; the panel filters by sender.tab.id to ignore other tabs. For a continuous stream (selection changes, scroll position), a port opened with chrome.tabs.connect from the panel is cleaner, and must be reopened when the active tab changes. A global side panel stays open across tab switches, so “which tab am I talking to?” is a moving target.
Step-by-step: a reliable panel–page connection
1. Track the active tab in the panel
1// sidepanel.js
2let activeTabId = null;
3const myWindowId = (await chrome.windows.getCurrent()).id;
4
5async function refreshActiveTab() {
6 const [tab] = await chrome.tabs.query({ active: true, windowId: myWindowId });
7 if (tab?.id === activeTabId) return;
8 activeTabId = tab?.id ?? null;
9 await onActiveTabChanged(tab);
10}
11chrome.tabs.onActivated.addListener(({ windowId }) => { if (windowId === myWindowId) refreshActiveTab(); });
12chrome.tabs.onUpdated.addListener((id, change) => { if (id === activeTabId && change.status === "complete") onActiveTabChanged(null, true); });
13refreshActiveTab();
Execution context: the side panel page. Each window has its own side panel instance, so filter tab events to the panel’s own window. Navigation within the active tab replaces the content script, so reload panel state when the tab finishes loading. If you use per-tab panels (sidePanel.setOptions({ tabId })), the panel only lives in its own tab and this tracking simplifies — see showing different side panel content per tab.
2. Request data from the content script
1async function onActiveTabChanged(tab, reloaded = false) {
2 renderLoading();
3 try {
4 const outline = await chrome.tabs.sendMessage(activeTabId, { type: "get-outline" });
5 renderOutline(outline);
6 } catch {
7 renderUnavailable(); // no content script: browser page, store page, or not injected yet
8 }
9}
10
11// content script
12chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
13 if (msg.type === "get-outline") {
14 sendResponse([...document.querySelectorAll("h1, h2, h3")].map((h, i) => ({ i, level: +h.tagName[1], text: h.textContent.trim().slice(0, 120) })));
15 }
16});
Execution context: the side panel and a content script. tabs.sendMessage rejects with “Could not establish connection. Receiving end does not exist” when no content script is listening — on restricted pages, or on tabs opened before install. Treat that as a normal state with a friendly message. Only the content script’s frame 0 responds unless you target a frameId.
3. Push events from the page to the panel
1// content script
2let last = "";
3document.addEventListener("selectionchange", debounce(() => {
4 const text = getSelection()?.toString().trim().slice(0, 2000) ?? "";
5 if (text === last) return;
6 last = text;
7 chrome.runtime.sendMessage({ type: "selection-changed", text }).catch(() => {}); // no listener when panel is closed
8}, 200));
9
10// sidepanel.js
11chrome.runtime.onMessage.addListener((msg, sender) => {
12 if (sender.tab?.id !== activeTabId) return;
13 if (msg.type === "selection-changed") renderSelection(msg.text);
14});
Execution context: a content script and the side panel. runtime.sendMessage from a content script reaches every extension page; when the panel is closed, the promise rejects (or the worker receives it), so catch the error. Filtering by sender.tab.id ensures background tabs cannot overwrite the panel. Debouncing selectionchange avoids a message per mouse movement.
4. Use a port for continuous streams
1// sidepanel.js
2let port;
3function connectToTab(tabId) {
4 port?.disconnect();
5 try {
6 port = chrome.tabs.connect(tabId, { name: "panel" });
7 port.onMessage.addListener(handlePageEvent);
8 port.onDisconnect.addListener(() => { port = null; });
9 } catch { port = null; }
10}
11
12// content script
13chrome.runtime.onConnect.addListener((p) => {
14 if (p.name !== "panel") return;
15 const onScroll = () => p.postMessage({ type: "scroll", y: scrollY / (document.body.scrollHeight - innerHeight) });
16 addEventListener("scroll", onScroll, { passive: true });
17 p.onDisconnect.addListener(() => removeEventListener("scroll", onScroll));
18});
Execution context: the side panel and a content script. A port lets the content script attach listeners only while the panel is watching and remove them when it disconnects — no wasted work when the panel is closed or looking at another tab. Reconnect on every active-tab change. See long-lived connections with ports.
5. Act on the page from the panel
1outlineList.addEventListener("click", (e) => {
2 const i = e.target.closest("[data-i]")?.dataset.i;
3 if (i != null) chrome.tabs.sendMessage(activeTabId, { type: "scroll-to-heading", i: Number(i) });
4});
5
6// content script
7if (msg.type === "scroll-to-heading") {
8 const h = document.querySelectorAll("h1, h2, h3")[msg.i];
9 h?.scrollIntoView({ behavior: matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth", block: "start" });
10}
Execution context: the side panel and content script. Commands from the panel are ordinary messages. Respect reduced motion when scrolling the page on the user’s behalf.
6. Inject on demand when no content script exists
1async function ensureContentScript(tabId) {
2 try { await chrome.tabs.sendMessage(tabId, { type: "ping" }); }
3 catch { await chrome.scripting.executeScript({ target: { tabId }, files: ["content.js"] }); }
4}
Execution context: the side panel. Opening the side panel by clicking the toolbar icon grants activeTab for the current tab, so the panel can inject when the extension has no static content script there — but that grant does not extend to tabs the user switches to later. With host permissions, inject freely; without them, show “Click the toolbar icon to use Readable on this page”. See injecting into already-open tabs after install.
7. Relay through the worker when the panel may be closed
If the page produces events that matter even when the panel is closed (counts for the badge, saved highlights), send them to the service worker and store the result; the panel reads stored state when it opens. Direct panel messaging is for live UI only.
Common mistakes
- Not tracking the active tab. The panel talks to a stale tab.
- Ignoring
sender.tab.id. Background tabs overwrite the panel. - Uncaught “Receiving end does not exist”. Treat missing content scripts as normal.
- Ports never reconnected. The stream stops after a tab switch.
- Filtering tab events by any window. Each window has its own panel.
Cross-browser variation
- Chrome / Edge: side panel is an extension page with
tabs.sendMessage,tabs.connectandscripting. - Firefox: the sidebar (
sidebar_action) is the equivalent; the same messaging works, andbrowser.windows.getCurrent()identifies its window. - Safari: no side panel API; use a popup or an injected panel instead. See side panel support across browsers.
Verification
- Open the panel, switch between tabs, and confirm the outline follows the active tab.
- Select text in a background tab (via another window) and confirm the panel ignores it.
- Open the panel on a browser page and confirm the unavailable message appears instead of an error.
- Close the panel and confirm content-script scroll listeners are removed.
FAQ
Can the content script open the side panel?
Not directly. Send a message to the worker, which can call sidePanel.open if it still has a user gesture — usually it does not, so prefer a toolbar or context menu trigger.
Do side panel and content script share storage?
Both can use chrome.storage; content scripts need setAccessLevel for storage.session.
How do I handle iframes?
Pass frameId in tabs.sendMessage options, or use scripting.executeScript with allFrames.
Does the panel need the tabs permission?
Reading tab.url or tab.title for the active tab does, unless the extension has host access to that site. Tab ids, activation events and messaging work without it.
Related
- Building a side panel UI in MV3 — the panel itself.
- Showing different side panel content per tab — per-tab panels.
- Responsive layout for a resizable side panel — rendering the data.
- Side panel and DevTools interfaces — the parent topic.