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.
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
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.
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.
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
- 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. - Add a
debuggerstatement, reload the page with DevTools open, and confirm it pauses in the Content scripts tree. - 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.
- 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.
Related
- Finding the right DevTools target for each context — the other contexts’ inspectors.
- Best practices for content script isolation in MV3 — what the isolated world protects.
- Bridging data between main world and isolated world — when you need both worlds.
- Debugging extension contexts — the parent guide.