Debugging a Popup That Renders Blank

Work through the causes of an empty MV3 popup — CSP-blocked inline scripts, module MIME types, a rejected top-level await, zero-height layout and a missing default_popup.

Published September 18, 2026 Updated September 18, 2026 8 min read
Table of Contents

A blank popup gives you almost nothing to work with: no error in the page console, no error on the extensions page, just a small white rectangle. The reason is that the popup’s own console is a separate inspector most developers do not know exists, and until you open it the failure is invisible. Once you can see it, the causes are a short and finite list. This guide is part of extension popup architecture.

Open the right inspector first

Right-click the toolbar icon and choose Inspect popup. This opens DevTools attached to the popup document and — importantly — pins the popup open, so it will not vanish while you work. Everything below assumes that console is in front of you.

Triaging a blank popupA decision tree starting from whether the popup console shows an error, branching into CSP violations, module loading failures, layout collapse and manifest problems.What does the popup's own console show?A CSP violationInline script or remote c…move it to a fileCheck every <script> and …including injected onesA module errorMIME type or bad import p…check the emitted filetype=module needs .js ser…and absolute-ish pathsAn uncaught rejectionTop-level await threwnothing after it ranWrap the entry in try/cat…render an error stateNothing at allLayout or manifestzero height, or no default_po…Inspect the body's boxit is probably 0×0
The two branches on the right are the ones with no console error — which is why they take longest to find.

Step-by-step: the five causes, in order of frequency

1. An inline script blocked by CSP

MV3 forbids inline script in extension pages. An onclick attribute or a <script> block with content is silently refused, and if that script was your entire application the page renders whatever static HTML it had — often nothing.

1<!-- Blocked: inline handler and inline script -->
2<button onclick="save()">Save</button>
3<script>document.body.dataset.ready = "1";</script>
4
5<!-- Allowed: external file, listener attached in code -->
6<button id="save">Save</button>
7<script type="module" src="popup.js"></script>

Execution context: the popup document, under the extension’s own CSP — which is stricter than the page CSP and cannot be relaxed to allow unsafe-inline. The console message names the directive; the fix is always to move the code into a file, as covered in fixing CSP violations in extension pages.

2. A module script that never loaded

<script type="module"> fails silently in more ways than a classic script. A wrong path, a missing file after a build, or a bare import specifier all produce a console error and an unexecuted module.

1// Fails in a browser: bare specifiers are not resolved
2import { render } from "utils";
3
4// Works: a relative path with an extension
5import { render } from "./utils.js";

Execution context: the popup document. Extension pages have no import map and no bundler at runtime, so every import must resolve to a real file the build emitted. Check the Network tab in the popup’s inspector — a failed module shows as a 404 against a chrome-extension:// URL.

3. A top-level await that rejected

A module entry point with await at the top level stops at the first rejection, and everything after it — including your render call — never runs. There is an unhandled rejection in the console, but it is easy to miss among extension noise.

 1// Fragile: one rejection blanks the popup
 2const settings = await chrome.storage.sync.get("settings");
 3render(settings);
 4
 5// Robust: always render something
 6try {
 7  const { settings } = await chrome.storage.sync.get("settings");
 8  render(settings ?? DEFAULTS);
 9} catch (err) {
10  renderError(err);
11}

Execution context: the popup document. Rendering an error state rather than nothing is worth the six lines — a popup that says “couldn’t load settings” is a bug report you can act on; a blank one is not.

4. A body with no height

Popups size themselves to their content. If the body’s content is absolutely positioned, or the root element is height: 100% with no parent height, the popup computes a zero-height box and the browser draws nothing.

1/* Collapses: 100% of an auto-height parent is zero */
2html, body { height: 100%; }
3#app { position: absolute; inset: 0; }
4
5/* Works: give the popup an explicit size */
6body { min-width: 320px; min-height: 180px; margin: 0; }

Execution context: the popup’s stylesheet. Check it by selecting <body> in the popup inspector’s Elements panel and reading the computed box — a 0 × 0 there is conclusive. Popup sizing rules are covered in fixing popup size and overflow issues.

5. The manifest does not point at the file you think

1{
2  "action": {
3    "default_popup": "popup.html",     // relative to the extension root, not to src/
4    "default_icon": { "16": "icons/16.png" }
5  }
6}

Execution context: parsed at install. If default_popup is missing entirely, clicking the action fires chrome.action.onClicked instead of opening a popup — so “nothing happens” rather than “blank popup”. If the path is wrong, Chrome shows an empty popup with a 404 in the inspector’s Network tab. After a build, confirm the emitted path matches the manifest.

The service worker is not the cause

A common misdiagnosis: the popup is blank, the service worker is asleep, and the two get connected. They are independent. The popup renders from its own document and its own scripts; a sleeping worker can only delay data, never prevent the page from painting — and if it does, the render path is wrong, as described in loading popup data without a flash of empty UI.

A quick way to rule it out: stop the worker from chrome://extensions and open the popup. A correctly built popup still renders its shell.

Symptom to causeFive blank-popup symptoms mapped to where the evidence appears and the usual fix.SymptomWhere the evidence isFixCSP violation loggedPopup consoleMove code to a file404 on a .js filePopup Network tabFix the path or the buildUnhandled rejectionPopup consoletry/catch the entryConsole clean, body 0×0Elements → computed boxmin-width / min-heightNothing opens at allmanifest.jsonAdd default_popup
Only the last two rows leave the console clean — which is why they are the ones that take an afternoon.

Making the failure visible next time

The reason a blank popup costs an afternoon is that the failure is silent by construction. Two small additions make the next occurrence self-reporting.

A render sentinel. Mark the document once the entry point has done its job, and show a fallback if it never does.

1<body>
2  <div id="app"></div>
3  <noscript>Scripting is disabled for this extension.</noscript>
4  <div id="boot-error" hidden>Something went wrong loading this popup.</div>
5</body>
 1// popup.js
 2const bootTimer = setTimeout(() => {
 3  document.querySelector("#boot-error").hidden = false;
 4}, 1500);
 5
 6try {
 7  await boot();
 8  clearTimeout(bootTimer);
 9} catch (err) {
10  clearTimeout(bootTimer);
11  document.querySelector("#boot-error").hidden = false;
12  console.error("[popup] boot failed", err);
13}

Execution context: the popup document. The timer covers the case the try cannot — a module that never executed at all, because of CSP or a 404 — since in that case no JavaScript of yours runs and only the static HTML plus the timer that was never set remains. For that case the fallback must be visible by default and hidden by the script:

1<div id="boot-error">Something went wrong loading this popup.</div>
1document.querySelector("#boot-error").remove();   // first line of popup.js

Execution context: the popup document. Inverting the default is the version that actually catches a script which never ran — it is worth the slightly odd-looking first line.

A logged boot. Write a timestamp to session storage on every successful boot. A user reporting a blank popup can then be asked for one value rather than for a DevTools screenshot, and its absence is diagnostic on its own.

Which failures each guard catchesA boot sequence showing the static fallback visible from parse, removed by the first line of the entry script, and re-shown by the error handler or the timeout.ParserFallback elementpopup.jsUserrenders visiblebefore any scriptmodule evaluatedremove()first lineboot() throwsre-insert the messagea sentence instead of a blank box
The fallback is visible by default precisely so that a script which never runs leaves it on screen.

Verification

Verification

  1. With the popup inspector open, confirm the document and its scripts loaded:
1({
2  scripts: [...document.scripts].map((s) => s.src || "(inline)"),
3  bodyBox: document.body.getBoundingClientRect().toJSON(),
4});
5// { scripts: ["chrome-extension://…/popup.js"], bodyBox: { width: 360, height: 220, … } }

Execution context: the popup’s DevTools console. An (inline) entry means a script the CSP will have refused; a zero-size bodyBox means the layout collapsed.

  1. Stop the service worker and reopen the popup — it must still render.
  2. Load the extension in a fresh profile. A popup that works in your development profile and not a clean one usually means a storage value your code assumes exists.
  3. Check chrome://extensions → Errors for anything logged before the popup document was created.

FAQ

Why is there no error on the extensions page?

The errors page collects service worker and manifest errors. A popup document’s errors go to the popup’s own inspector and nowhere else, which is why the first step is opening it.

The popup flashes and closes immediately — is that the same bug?

No. That is usually a window.close() running unconditionally, or an uncaught error in a click handler that the browser treats as a dismissal. Pin the popup with the inspector and watch the console during the flash.

It works unpacked but is blank after upload — why?

Almost always a file that was not included in the package, or a path that is case-sensitive on the packing machine. Unzip the uploaded artifact and check the popup’s files are present with the exact casing the manifest uses.

Other MV3 Architecture & Extension Lifecycle Resources