Skip to content

How to debug a Chrome extension popup that closes or shows nothing

7 min readModerok team

Inspect popup, reload from the Network panel, and the manifest, CSP, and module-path causes of a Chrome extension popup that renders blank.

To debug a Chrome extension popup you need the popup's own DevTools, and Chrome's Hello World tutorial gives the gesture in three steps: "Open the popup." then "Right-click the popup." then "Select Inspect." Keeping it attached is the other half. Chrome's Debug extensions tutorial notes that "The popup often makes all of the required network requests before even the speediest of developers can open DevTools," and gives the workaround: "To view these requests, refresh from inside the network panel. It reloads the popup without closing the DevTools panel." Manifest and service worker edits still need a full extension reload. If the popup renders nothing, check the manifest path:

{
  "manifest_version": 3,
  "name": "My extension",
  "version": "1.0",
  "action": { "default_popup": "popup.html" },
  "permissions": ["storage"]
}

What the Errors page shows, and what it misses

Check the Errors button on the extensions page first, because it does catch popup exceptions. Chrome's debugging tutorial walks through exactly that, an Uncaught ReferenceError thrown by popup code: "Navigate back to the Extensions Management page. The Errors button reappears. Click it to view the new log."

What lands there is bounded, though. The tutorial says so while explaining content scripts, but the sentence is about the page itself: "This is because only runtime errors, console.warning and, console.error are recorded on the Extensions Management page." A popup that renders blank without throwing has nothing to record, so what is left is the popup's own console and Network panel. That console dies with the popup, which is why the refresh trick above matters here.

The popup closes the instant you click away

This is documented behaviour, not a bug. Chrome's Add a popup guide is blunt: "There is no way to keep the popup open after the user has clicked away." MDN's Popups page adds the other way it vanishes: "The popup can be closed programmatically by calling window.close() from a script running in the popup."

That guide also names the trigger: "Popups automatically close when the user focuses on some portion of the browser outside of the popup." So focus is what to watch: if the popup vanishes the moment a button is pressed, look for something in that handler that moves it. chrome.tabs.create() is worth ruling out first: its active property is "Whether the tab should become the active tab in the window" and "Defaults to true", though the entry adds it "Does not affect whether the window is focused (see windows.update)" (chrome.tabs). Trying active: false costs nothing.

For longer sessions, load the page as an ordinary tab at chrome-extension://<your-extension-id>/popup.html, the same trick the debug tutorial uses for manifest.json. Remember you are testing a tab: nothing dismisses it on an outside click, and content-driven sizing is gone.

Debug a Chrome extension popup that renders blank

Work down this list in the popup's own DevTools.

1. The manifest path. Chrome's action API reference says of default_popup: "If present, this property should point to a relative path within the extension directory." Confirm the file sits at that path inside the packed extension, not just in your source tree.

2. A 404 in the Network panel. Refresh from the Network panel and look for a failed request. A <script type="module" src="./popup.js"> with a wrong relative path, or a base path changed by your bundler, shows up here and, per the quote above, not on the Errors page.

3. Inline script blocked by CSP. Chrome's content_security_policy reference gives the default extension_pages policy as script-src 'self'; object-src 'self';, and of the separately listed minimum, script-src 'self' 'wasm-unsafe-eval'; object-src 'self';, it says: "The extension_pages policy cannot be relaxed beyond this minimum value." Neither leaves room for inline script, so an onclick="" attribute or a <script> block in popup.html is dropped. We covered the error text and the external-file fix in Fix "Refused to execute inline script" in a Chrome extension.

4. A top-level await that rejects. If your entry module awaits at the top level, check that promise. Give the popup a visible failure state so you read an error instead of guessing:

try {
  const { settings } = await chrome.storage.local.get("settings");
  render(settings ?? {});
} catch (err) {
  document.body.textContent = `Popup failed: ${err.message}`;
  console.error(err);
}

5. A body with no laid-out height. The action reference says the popup "can contain any HTML contents you like, and will be automatically sized to fit its contents" and that "The popup's size must be between 25x25 and 800x600 pixels." Because sizing follows the content, a root element that is absolutely positioned or mounted into a container with no intrinsic size gives Chrome little to measure, and you get a panel near that documented 25x25 floor with nothing readable in it. The giveaway is DevTools showing your DOM present and populated. Set an explicit width and min-height on body before hunting for a script bug.

The background click handler that went dead

If you added a popup and your service worker's click handler stopped running, that is documented. The action reference: "The action.onClicked event won't be sent if the extension action has specified a popup to show on click of the current tab." You cannot have both for the same tab. To drop the popup at runtime and get the event back, call chrome.action.setPopup({ popup: "" }): "If set to the empty string (''), no popup is shown."

Opening the popup from code is version gated: the reference lists openPopup() as "Chrome 127+" and notes that "Between Chrome 118 and Chrome 126, this is only available to policy installed extensions." You are not stuck with the icon below 127, though. The Add a popup guide lists three triggers: "It's triggered by a keyboard shortcut, by clicking the extension's action icon or by calling browser.action.openPopup()." That shortcut is the reserved _execute_action command in the manifest's commands key.

Work that dies with the popup

Once you accept that the popup can vanish mid-task, the architecture follows. Anything that must finish, a network call, a write, an analytics event, belongs in the service worker, with the popup sending a message rather than awaiting a result. If you do await one and it never arrives, Fix "The message port closed before a response was received" covers the failure modes on the receiving side.

Firefox differences

Firefox gives you the thing Chrome does not: a way to pin the popup open. MDN's Popups page: "You can debug a popup's markup and JavaScript using the Add-on Debugger, but you'll need to turn on the Disable popup auto-hide feature to prevent popups from hiding when you click outside them." Extension Workshop's Debugging page has the mechanics: open about:debugging, "click Inspect next to your extension", then "in the options menu, click Disable Popup Auto-Hide." Then "when you open a popup it stays open and its code remains loaded."

Two caveats, both from that page: "Disable popup auto-hide also applies to built-in browser popups, such as the options menu," which makes ordinary browsing awkward, and "When you close the window, the setting reverts to auto-hide popups." It maps to the ui.popup.disable_autohide preference in about:config.

What still breaks after the fix

Having DevTools open changes the system you are measuring. Chrome's debugging tutorial is explicit: "Key point: Inspecting the service worker keeps it active," followed by "To ensure your extension behaves correctly when your service worker is terminated, remember to close DevTools." Your message still arrives once you close it, because the service worker lifecycle page says "if the service worker has gone dormant, an incoming event will revive them." What does not survive is in-memory state, hence that page's "Persist data rather than using global variables." A popup handler assuming the worker remembers what it was told a minute ago works all afternoon under DevTools and fails in production.

None of this reaches you after you ship, either. A popup whose top-level await rejects against one user's stored data logs to a console nobody is looking at. Closing that gap means reporting the failure somewhere that outlives the popup. Moderok's SDK records uncaught exceptions and unhandled promise rejections as __error events in every context where you call Moderok.init(). A catch block like item 4's has already handled the error, though, and console.error is not captured unless you opt in, so swap that console.error(err) for Moderok.captureError(err, { action: "popup_load" }). One caveat on the report itself: events are batched and persisted on a debounce, so a popup that closes immediately may not get its own error out. Relay it with chrome.runtime.sendMessage and call captureError in the service worker handler instead. The error tracking guide covers what is automatic and what needs an explicit call.