Opening a Popup from the Service Worker in MV3
Learn how to programmatically open a Chrome extension popup from a service worker using chrome.action.openPopup(), with fallbacks for older browsers and no-gesture contexts.
Learn how to programmatically open a Chrome extension popup from a service worker using chrome.action.openPopup(), with fallbacks for older browsers and no-gesture contexts.
When background logic needs to surface information to the user — say, after completing a network request or detecting a page condition — the instinct is to open the extension popup programmatically. In practice this runs headfirst into one of MV3’s sharpest constraints: service workers have no access to user gestures, and the API that controls the popup, chrome.action.openPopup(), requires one. Understanding exactly where that line sits, and what alternatives exist when you cannot cross it, is the subject of this page. For a broader look at how popup architecture fits into the extension lifecycle, see Extension Popup Architecture.
In Manifest V2, background pages were persistent and could hold onto DOM event listeners indefinitely. User-gesture flags could be retained across asynchronous callbacks under certain conditions. MV3 replaces that persistent background page with an event-driven service worker that spins up only in response to registered events and shuts down when idle. This architectural shift has a direct consequence for popup control: the user-gesture flag is attached to the originating event, and most service worker events — alarms, fetch events, message events from content scripts — carry no gesture at all. Only events that flow directly from a real UI interaction, like a click on the action icon, propagate the gesture flag into the service worker’s execution context.
User clicks icon
│
▼
chrome.action.onClicked fires
[user-gesture flag: TRUE]
│
▼
chrome.action.openPopup() ← allowed
chrome.alarms.onAlarm fires
[user-gesture flag: FALSE]
│
▼
chrome.action.openPopup() ← throws / silently fails
The practical upshot is that you can only call openPopup() from a narrow set of privileged entry points, and you need a suite of fallbacks for everything else.
chrome.action.openPopup was added in Chrome 127 (stable, July 2024). Any user still on an earlier Chromium build, or on Firefox or Safari, will have undefined here. Check before calling.
1// service-worker.js
2function canOpenPopup() {
3 return typeof chrome.action.openPopup === "function";
4}
Execution context: service worker global scope (self). chrome is available as a global; window and document are not. Firefox 128 and Safari return undefined here — this check is the gate for all subsequent logic.
chrome.action.onClicked fires when the user clicks the extension icon and no default popup is set (i.e., action.setPopup("") was called or no popup was configured). This is the only reliable service-worker context that carries a user gesture.
1// service-worker.js
2chrome.action.onClicked.addListener(async (tab) => {
3 if (!canOpenPopup()) {
4 // fall through to Step 3 or Step 4
5 return;
6 }
7
8 try {
9 await chrome.action.openPopup();
10 } catch (err) {
11 // Can throw if no window is focused or popup is already open
12 console.warn("openPopup failed:", err.message);
13 }
14});
Execution context: service worker, inside a chrome.action.onClicked callback. This event carries a user-gesture flag, making chrome.action.openPopup() valid here. Chrome 127+. Edge follows Chromium and supports this from the equivalent build. Firefox and Safari do not fire this event path with openPopup support.
openPopup() opens the popup attached to the currently focused browser window. It returns a Promise<void> — there are no arguments and no way to target a specific window. If the popup is already open, the promise resolves silently without doing anything a second time.
When chrome.action.openPopup is unavailable, you can open popup.html in a detached chrome window that looks and behaves like a popup. This works in any Chromium version and requires no additional permissions beyond "popup.html" being in web_accessible_resources if you are loading it from a content script — from the service worker, no extra permission is needed.
1// service-worker.js
2async function openPopupFallback() {
3 const width = 400;
4 const height = 600;
5
6 // Try to position over the current window
7 const [currentWindow] = await chrome.windows.getAll({ populate: false });
8 const left = currentWindow
9 ? Math.round(currentWindow.left + (currentWindow.width - width) / 2)
10 : 100;
11 const top = currentWindow ? currentWindow.top + 60 : 100;
12
13 await chrome.windows.create({
14 url: chrome.runtime.getURL("popup.html"),
15 type: "popup",
16 width,
17 height,
18 left,
19 top,
20 });
21}
Execution context: service worker. chrome.windows.create is available in all three major browsers (Chrome, Firefox, Safari) and does NOT require a user gesture as of Chrome 122+, though behaviour under gesture-free contexts may vary by platform. The resulting window is independent of the browser toolbar and will not close when the user clicks away, unlike a true action popup.
When your service worker wakes on an alarm, a push message, or a fetch event, there is no path to openPopup(). The correct substitute is chrome.notifications.create, which surfaces a system notification the user can click to trigger further action.
1// service-worker.js
2chrome.alarms.onAlarm.addListener(async (alarm) => {
3 if (alarm.name !== "check-update") return;
4
5 // Cannot call openPopup here — no user gesture
6 await chrome.notifications.create("update-ready", {
7 type: "basic",
8 iconUrl: chrome.runtime.getURL("icons/icon-128.png"),
9 title: "Update available",
10 message: "Click to review the changes in your extension.",
11 priority: 1,
12 });
13});
14
15// When the user clicks the notification, THAT carries a gesture
16chrome.notifications.onClicked.addListener(async (notificationId) => {
17 if (notificationId !== "update-ready") return;
18 await chrome.notifications.clear(notificationId);
19 if (canOpenPopup()) {
20 await chrome.action.openPopup();
21 } else {
22 await openPopupFallback();
23 }
24});
Execution context: both listeners run in the service worker. chrome.alarms.onAlarm carries no gesture flag. chrome.notifications.onClicked does carry a user gesture, making openPopup() valid inside it. Requires the "notifications" permission in manifest.json. Firefox supports chrome.notifications; Safari has partial support — test notification click propagation carefully on Safari 17+.
If your use case demands UI that stays open across navigation and does not depend on a gesture to appear, chrome.sidePanel.open() is a strong alternative. It requires Chrome 114+ and the "sidePanel" permission, and it can be called from the onClicked handler with the current tab.windowId.
1// manifest.json (excerpt)
2// "permissions": ["sidePanel"]
3// "side_panel": { "default_path": "sidepanel.html" }
4
5// service-worker.js
6chrome.action.onClicked.addListener(async (tab) => {
7 if (typeof chrome.sidePanel?.open === "function") {
8 await chrome.sidePanel.open({ windowId: tab.windowId });
9 } else if (canOpenPopup()) {
10 await chrome.action.openPopup();
11 } else {
12 await openPopupFallback();
13 }
14});
Execution context: service worker, inside chrome.action.onClicked. chrome.sidePanel is a Chrome/Edge-only API — it is undefined on Firefox (which uses its own sidebar_action API) and on Safari. The "sidePanel" permission must be declared in manifest.json. See Side Panel and DevTools Interfaces for deeper coverage.
chrome.action.openPopup() is available and stable. Call it from onClicked or any other user-gesture-bearing event. Throws with “chrome.action.openPopup is not available without a user gesture” when called outside a gesture context.chrome.action.openPopup is undefined. Use chrome.windows.create({ type: "popup" }) as a substitute.openPopup() identically to Chrome.chrome.action.openPopup. The property is undefined. Use notifications (chrome.notifications.create) or tabs (chrome.tabs.create) as fallback. Firefox has its own sidebar_action API for side-panel-style UI, which is not compatible with Chrome’s sidePanel API.chrome.action.openPopup. Also lacks chrome.sidePanel. Notifications work via browser.notifications with the "notifications" permission. chrome.windows.create({ type: "popup" }) works as a last resort.chrome.action.onClicked carries a gesture. chrome.notifications.onClicked carries a gesture in Chrome and Firefox; verify Safari behaviour on each release.chrome://extensions, enable Developer mode, and click Service worker to open the service worker DevTools console.action.setPopup("") or no default_popup key in the manifest). If a default popup is configured, onClicked will not fire."chrome.action.openPopup is not available without a user gesture", the call is not inside an onClicked handler — trace the call stack.chrome.alarms.create("check-update", { when: Date.now() + 2000 }) in the service worker console, then watch for the system notification to appear roughly two seconds later.windows.create fallback, temporarily set canOpenPopup to always return false in your local build, trigger the icon click, and verify a detached window appears at popup.html.openPopup() returns a promise and errors are swallowed if you omit await or .catch().No. Content scripts run in the web page’s renderer process and do not have access to chrome.action. To trigger popup-opening logic from a content script, send a message to the service worker via chrome.runtime.sendMessage and handle it there — but remember the service worker still needs a user gesture at the time of that message if you want to call openPopup(). A message event alone does not carry a gesture. See Implementing Background Messaging Between Popup and Service Worker for the messaging setup.
No. chrome.alarms.onAlarm does not carry a user gesture, so chrome.action.openPopup() will throw. The correct pattern is to fire a chrome.notifications.create call from the alarm handler, then call openPopup() inside chrome.notifications.onClicked — which does carry a gesture. This gives the user control over when they open the popup rather than having it appear unexpectedly.
If no popup document is registered, chrome.action.openPopup() resolves without doing anything — it does not throw. Before calling, you can check the current popup URL with await chrome.action.getPopup({}). If that returns an empty string, register the popup first with await chrome.action.setPopup({ popup: "popup.html" }), then call openPopup(). Keep in mind that dynamic popup management and managing extension state across reloads interact: a service worker restart clears any runtime state, so store the desired popup path in chrome.storage if you toggle it conditionally.
Not directly — chrome.action.openPopup() takes no arguments and there is no payload channel built into the call. The standard pattern is to write the data to chrome.storage.session (cleared when the browser closes, fast reads) immediately before calling openPopup(), then have the popup read that storage key in its DOMContentLoaded handler. chrome.storage.session is available in both the service worker and the popup document without extra permissions.
Submit MV3 extensions to Chrome Web Store, Firefox AMO and Safari, justify permissions under least-privilege, meet privacy disclosure requirements and avoid common rejection causes.
Inject JavaScript and CSS into web pages with MV3 content scripts — isolated worlds, declarative vs programmatic injection, run_at timing, MAIN world risks, and Shadow DOM UI patterns.
Build MV3-compliant browser extension popups: ephemeral lifecycle, storage hydration, service worker messaging, and cross-browser constraints explained.
Register options_ui or options_page in Manifest V3, persist user preferences through chrome.storage.sync, and keep every extension context in sync via onChanged.