Back to Blog
Web Development

Chrome Extension Side Panel Opens on Every Tab? Here's the Per-Tab Fix

Paul Mulligan March 16, 2026
Share:
Chrome Extension Side Panel Opens on Every Tab? Here's the Per-Tab Fix

You build a Chrome extension with a side panel. You wire up the action click handler, pass the tabId to sidePanel.open(), and expect the panel to appear only on the tab where the user clicked. Then you switch to another tab and the panel is still there. You open a third tab and it follows you there too. The side panel you carefully scoped to a single tab is now a global panel plastered across every tab in the window.

This is one of the most confusing behaviors in Chrome's Manifest V3 sidePanel API, and it has tripped up enough developers that there are multiple open issues on the Chrome extensions repo and the W3C webextensions tracker. The documentation doesn't make the relationship between manifest configuration and runtime behavior obvious, and the fix requires two changes that only work together.

The Symptom

You have a manifest.json that declares a side panel with a default path, and a service worker that opens the panel when the user clicks the extension icon. The manifest looks something like this: you have the sidePanel permission, a service_worker entry, and a side_panel block with a default_path pointing to your panel HTML file. Your service worker calls chrome.sidePanel.open() with the active tab's ID. Everything looks correct according to the docs. But the panel opens on every tab.

Why tabId in open() Doesn't Do What You Think

The mental model most developers have is that passing tabId to sidePanel.open() means 'open this panel only on this tab.' That's reasonable, but it's wrong when you have side_panel.default_path in your manifest. What default_path actually does is tell Chrome to create a global panel instance that is available on every tab by default. When you then call sidePanel.open({ tabId }), Chrome opens the panel on that tab, but because a global panel already exists, it remains visible everywhere. The tabId parameter in open() specifies which tab's context to use for opening, not which tab to restrict the panel to.

This behavior is documented indirectly in the Chrome sidePanel API reference, but the implications aren't spelled out. The GitHub issue #987 on the chrome-extensions-samples repo captures the confusion perfectly: a developer opens the panel with a tabId, switches tabs, and the panel stays open. A Google Chrome team member confirmed in that thread that removing the default_path from the manifest is part of the solution.

The Two-Part Fix

Getting true per-tab panel scoping requires two changes that work together. Neither one alone is sufficient. First, remove the side_panel.default_path entry from your manifest.json entirely. Your manifest should still declare the sidePanel permission and your service worker, but the side_panel block with default_path must be gone. This prevents Chrome from creating a global panel instance.

Second, at service worker startup, globally disable the side panel by calling chrome.sidePanel.setOptions({ enabled: false }). This ensures that no tab has a panel available by default. From there, you enable the panel only for specific tabs by calling setOptions with a tabId, a path, and enabled: true. The startup sequence in your service worker should first call chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: false }) to prevent Chrome from auto-opening the panel when the icon is clicked, then call chrome.sidePanel.setOptions({ enabled: false }) to disable the panel globally.

The Action Click Handler

With the panel disabled globally and no default_path in the manifest, you need to manually enable and open the panel when the user clicks your extension icon. In your chrome.action.onClicked listener, you receive the tab object. You call chrome.sidePanel.setOptions() with that tab's ID, your panel HTML path, and enabled: true. Then immediately after, you call chrome.sidePanel.open() with the same tabId. One important detail: the setOptions call should not be awaited before calling open(). If you await setOptions first, you may hit a timing issue where Chrome reports that open() can only be called in response to a user gesture, because the async gap breaks the gesture chain.

Tab Switching Behavior

Enabling the panel for a specific tab is only half the story. You also need to handle what happens when the user switches between tabs. If tab A has the panel open and the user switches to tab B, you want the panel to disappear on tab B (unless it was also explicitly opened there). This requires a chrome.tabs.onActivated listener that checks whether the newly active tab is one of your 'panel tabs' and enables or disables the panel accordingly.

The pattern is to maintain a Set of tab IDs where the panel is active. When a tab becomes active, check if it's in the set. If it is, call setOptions with that tabId, your panel path, and enabled: true. If it's not in the set, call setOptions with that tabId and enabled: false. This gives you clean per-tab scoping where the panel only shows up on tabs where the user explicitly opened it.

The Chicken and Egg Problem

There's a subtle edge case with the first click after installation. When your extension is freshly installed and the user clicks the icon for the first time, your panelTabs Set is empty. If your onActivated listener runs before the first click and tries to disable the panel for the current tab, it could interfere with the click handler. The solution is to skip per-tab scoping in the onActivated handler when panelTabs is empty. If no tabs have the panel open yet, there's nothing to scope, and trying to scope prematurely can cause race conditions with the initial open call.

Surviving Service Worker Restarts

Manifest V3 service workers are not persistent. Chrome can terminate and restart your service worker at any time, which means any in-memory state like your panelTabs Set will be lost. If the service worker restarts while the user has the panel open on two tabs, those tab IDs are gone and your onActivated handler will start disabling the panel everywhere.

The fix is to persist the panelTabs set using chrome.storage.session. This storage area survives service worker restarts but is cleared when the browser closes, which is the right lifetime for this kind of state. Every time you add or remove a tab from the set, write the updated array to session storage. At service worker startup, read the stored array and rebuild the Set. This way, the service worker can pick up where it left off after a restart.

Cleaning Up Closed Tabs

You also need a chrome.tabs.onRemoved listener to clean up your panelTabs Set when a tab is closed. Without this, your set would grow indefinitely with stale tab IDs. When a tab is removed, delete it from the Set and update session storage. This is straightforward but easy to forget, and stale tab IDs can cause confusing behavior if the same ID is later recycled by Chrome.

Testing the Behavior

Writing tests for this pattern is important because the interactions between global disable, per-tab enable, tab switching, and service worker restarts are complex. A good test suite should verify that opening the panel on one tab doesn't affect other tabs, that switching away from a panel tab disables the panel on the new tab, that switching back to a panel tab re-enables it, and that closing a panel tab cleans up the state correctly.

In tests, you can mock the chrome.sidePanel, chrome.action, chrome.tabs, and chrome.storage.session APIs to verify that your service worker calls them with the right arguments in the right order. The key assertions are: setOptions is called with enabled: false at startup, setOptions is called with the correct tabId and enabled: true when the icon is clicked, the onActivated handler correctly enables and disables per tab, and session storage is updated whenever the panelTabs set changes.

The Complete Pattern

To summarize the full pattern: remove side_panel.default_path from your manifest. At service worker startup, disable the panel globally and restore any persisted tab state from session storage. In the action click handler, add the tab to your set, persist it, enable the panel for that tab, and open it. In the tab activated handler, enable or disable the panel based on whether the tab is in your set. In the tab removed handler, clean up the set and persist the change. The result is a side panel that only appears on tabs where the user explicitly requested it, disappears when they navigate to other tabs, and survives service worker restarts.

Resources

The Chrome sidePanel API documentation covers the API surface but doesn't explicitly address this per-tab scoping pattern. The GitHub issue #987 on the chrome-extensions-samples repository is the most useful discussion thread, where a Chrome team member confirmed that removing default_path from the manifest is the key insight. The W3C webextensions issue #515 proposes first-class support for per-tab panel instances in the openPanelOnActionClick behavior, which would make this entire workaround unnecessary. Until that proposal is implemented, the pattern described here is the most reliable approach.


Building a website for a small business? I also build professional WordPress and Webflow sites for small businesses, starting at $1,000. If you or someone you know needs a site, check out my services or get in touch.

Paul Mulligan

Freelance Web Developer

Paul Mulligan is a freelance web developer based in Baltimore, MD with 10+ years of experience building WordPress and Webflow sites for small businesses. He focuses on clean design, fast performance, and real results.

Support My Open Source Work

I build free, open-source developer tools like Flavian and Aurelius. If you find my work helpful, consider supporting me on Patreon.

Support on Patreon

Related Articles

The Two-Agent Workflow: How I Use a Coding AI and a Browser AI Together to Build WordPress Sites

Read Article

Claude Code Chrome Extension WordPress Debugging

Read Article

Ready to Transform Your Business's Website?

Let's discuss how I can create a website that attracts and converts more customers.

Get a Free Consultation