Debugging Content Scripts in the Isolated World

Debug an MV3 content script where it actually runs — the console context selector, breakpoints in extension sources, why page globals are invisible, and diagnosing scripts that never inject.

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

A content script shares the page’s DOM and nothing else. Its variables, its window properties and its chrome.* bindings live in an isolated world the page cannot see — and the page’s DevTools console, by default, is attached to the page’s world, not yours. So you type the name of a variable your script definitely set and get undefined, conclude the script did not run, and spend an hour on a problem that does not exist. This guide is part of debugging extension contexts.

Two worlds, one DOM

What each world can seeThe page's main world and the extension's isolated world share the DOM but have separate JavaScript globals, and only the isolated world has chrome.runtime and chrome.storage.Page main worldsite JS, window.app, page globalsconsole default: "top"Shared DOMelements, attributes, eventsboth worlds read and write itExtension isolated worldyour variables, chrome.runtime, chrome.storageselect your extension in the dropdownOther extensions' worldseach isolated from yoursalso listed in the dropdown
The DOM is the only shared layer — every other difference follows from that.

Step-by-step

1. Switch the console to your world

Open DevTools on the page, go to Console, and use the context selector at the top left (it says “top” by default). Each extension with a content script in this page appears as its own entry.

1// In "top" (the page's world):
2typeof window.__readerState;    // "undefined"
3typeof chrome?.runtime?.id;     // "undefined"
4
5// After selecting your extension in the dropdown:
6typeof window.__readerState;    // "object"
7chrome.runtime.id;              // "abcdefghijklmnopabcdefghijklmnop"

Execution context: the page’s DevTools console, in two different worlds. Checking chrome.runtime.id is the fastest way to know which world you are typing into.

2. Set breakpoints in the right source tree

In the Sources panel, content scripts appear under a separate Content scripts tab in the file navigator, grouped by extension — not under the page’s own origin.

1// content/main.js
2function highlight(el) {
3  debugger;                      // pauses only when the page's DevTools is open
4  el.classList.add("reader-hl");
5}

Execution context: the content script’s isolated world. A debugger statement is a quick way to land in the right file without hunting through the navigator; remove it before building for release, or strip it with your bundler’s production mode.

3. Check whether the script injected at all

If your script genuinely is not running, the cause is almost always one of four things, each checkable in seconds.

1// From the service worker console: is a registration present?
2(await chrome.scripting.getRegisteredContentScripts()).map((s) => [s.id, s.matches]);
3
4// Is the page one you are allowed to touch?
5const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
6tab.url;   // "chrome://extensions/" would explain everything

Execution context: the service worker’s console. The four usual causes are: the URL does not match the pattern, the host permission was not granted, the page is restricted, or the script was registered after the page loaded and the tab was never reloaded. The restricted-page list is in handling restricted URLs and tab permissions.

4. Log with a prefix that says where you are

1const log = (...a) => console.debug("%c[reader:content]", "color:#1d4ed8", ...a);
2log("injected at", document.readyState, "frame", window === top ? "top" : "sub");

Execution context: the content script. Content-script logs appear in the page’s console mixed in with the page’s own output; a styled prefix makes them findable, and reporting the frame is essential on pages with iframes, where the same script may run several times.

5. See what the page sees

Sometimes the bug is the interaction: your script set an attribute, the page’s code reacted. To check the page’s view of a DOM node you touched, switch the dropdown back to “top” and inspect the same element.

1// In "top": what does the page's framework think this element is?
2$0.__reactProps$ ?? $0.dataset;

Execution context: the page’s main world. $0 is the element currently selected in the Elements panel, available in any console context. Reading the page’s own state for the same node is how you find out that a framework re-rendered and discarded your change.

6. Catch errors that would otherwise go to the page

An uncaught exception in a content script appears in the page’s console, where it is easy to mistake for a site bug. Report it to your own logs as well.

1self.addEventListener("error", (e) => {
2  if (!e.filename?.startsWith(chrome.runtime.getURL(""))) return;   // only our own files
3  chrome.runtime.sendMessage({ type: "log:error", message: e.message, file: e.filename, line: e.lineno }).catch(() => {});
4});

Execution context: the content script’s isolated world. Filtering by filename ensures you only capture errors from your own files rather than the page’s. The full cross-context capture pattern is in capturing uncaught errors in every context.

"My content script isn't running"A decision tree separating a script that runs in the wrong console context, a script that never injected, and a script that ran but whose effect the page undid.Does chrome.runtime.id resolve in the console?No — dropdown says "top"Wrong worldselect your extensionVariables now visibleno bugNo entry for my extensionNever injectedmatch, permission, restricted, reloadCheck registrationsfrom the workerYes, state looks rightRan, effect undonepage re-renderedInspect $0 in "top"see the page's view
The first branch accounts for most reports — check the console context before anything else.

Frames multiply everything

On a page with iframes, the context dropdown lists one entry per frame per world. Your extension may appear several times — once for the top frame and once for each frame your script injected into — and a variable set in the top frame’s isolated world is not visible from an iframe’s.

This is the usual explanation for “it works on the page but not in the widget”: the script ran in the top frame, while the widget is an iframe where the script either did not match or was not injected with all_frames. The frame labels in the dropdown show each frame’s URL, which is enough to tell them apart. Injection into sub-frames is covered in injecting into iframes and all frames.

1// Run in each frame's context to map where your script is alive
2({ href: location.href, isTop: window === top, loaded: !!window.__readerState });

Execution context: the page’s console, with each frame’s extension context selected in turn. A frame where loaded is false but you expected the script is a matching or injection problem, not a logic one.

Console contexts on a page with one iframeThe context dropdown lists the page's main world and your isolated world for the top frame, and the same pair again for the iframe.toppage main worldReader (top)your isolated worldshop.example/carttop frame URLand for the embedded framepay.other/widgetiframe main worldReader (pay.other)only if injectedMissing?all_frames or match
Four entries for one page — pick the frame first, then the world.

Cross-browser variation

  • Chrome / Edge: the console’s context dropdown lists each extension’s isolated world per frame; content scripts appear under Sources → Content scripts.
  • Firefox: the page’s Web Console has a context selector for content scripts as well; in some versions content-script logs are also shown in the Browser Console with the extension id.
  • Safari: Web Inspector shows extension content scripts under their own section in Sources, and the console’s execution context menu lists them. Enable the Develop menu first.
  • All three: the isolated world is per extension per frame. Two content scripts from the same extension in the same frame share a world; scripts from different extensions never do.

Verification

  1. Set a marker in your content script — window.__readerState = { ok: true } — and confirm it is visible only with your extension selected in the console dropdown.
  2. Add a debugger statement, reload the page with DevTools open, and confirm it pauses in the Content scripts tree.
  3. Confirm the script’s frames:
1// service worker console
2const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
3(await chrome.scripting.executeScript({ target: { tabId: tab.id, allFrames: true }, func: () => !!window.__readerState }))
4  .map((r) => [r.frameId, r.result]);
5// [[0, true], [7, false]]

Execution context: the service worker console. Frame 7 reporting false means your registered script did not reach it — matching or all_frames, not logic.

  1. Throw a deliberate error in the content script and confirm it reaches your own logs as well as the page console.

FAQ

Can the page see my content script’s variables?

No. The isolated world’s globals are invisible to page scripts. Only DOM changes and events cross.

Why can I see my DOM changes but not my variables in “top”?

Because the DOM is shared and variables are not. That is the isolation working as designed.

Do content-script breakpoints survive a page reload?

Yes, Chrome keeps breakpoints by file across reloads. A debugger statement is more reliable while the file path is still changing during development.

Other Testing, Debugging & Performance Optimization Resources