Debugging Extension Contexts in MV3
Debug every MV3 context systematically: service worker via chrome://extensions Inspect, content scripts in page DevTools, popup and options via right-click inspect, source maps and the Errors panel.
Debug every MV3 context systematically: service worker via chrome://extensions Inspect, content scripts in page DevTools, popup and options via right-click inspect, source maps and the Errors panel.
MV3 splits extension code across at least three isolated contexts — the service worker, content scripts, and extension pages (popup, options, sidepanel) — and each one requires a different DevTools entry point. The failure mode that confuses most developers is opening the wrong DevTools for the wrong context: looking for service worker logs in the popup console, or trying to inspect a content script variable from the extension’s background page. Nothing is in scope. This guide is part of the Testing, Debugging & Performance Optimization section and maps each context to its correct inspection path, covering source map configuration, cross-context logging patterns, and the Errors panel that surfaces registration failures before any DevTools is open.
chrome://extensions — without it the Inspect link is hidden and the Errors panel is not accessible.eval() or Function() constructor in service worker code — Chrome’s extension CSP blocks inline evaluation, and any attempt produces a CSP error that masks the real problem you are investigating.The service worker is not a page — it has no DOM and does not appear in the browser’s window list. Its DevTools instance is accessed through chrome://extensions.
Navigate to chrome://extensions, find your extension card, and click the Service worker link (shown as “Inspect views: Service Worker” or a blue “service worker” hyperlink). This opens a dedicated DevTools window attached to the worker’s V8 context.
1// background.ts — structured logging that survives source-map-less stacks
2const LOG_PREFIX = '[SW]';
3
4function log(event: string, data?: unknown) {
5 console.log(`${LOG_PREFIX} ${event}`, data ?? '');
6}
7
8chrome.runtime.onInstalled.addListener(details => {
9 log('onInstalled', details.reason);
10});
11
12chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
13 log('onMessage', { msg, tabId: sender.tab?.id });
14 // always return true if response is async
15 return true;
16});
Execution context: Service worker background context. console.log output appears only in the dedicated SW DevTools window opened via the chrome://extensions Inspect link — it does not appear in the page’s DevTools or in the popup’s console. Structured log prefixes like [SW] make it possible to filter output when you have many console lines streaming in.*
After opening SW DevTools, the Network tab shows all fetch() calls the worker makes (including calls to extension APIs that hit Chrome’s internal network layer), and the Application → Service Workers panel shows registration state, scope, and whether the worker is running or idle.
Content scripts run in the host page’s process but in an isolated JavaScript world. They appear in the page’s DevTools — not the extension’s — under Sources → Content scripts. Open DevTools on the host page (F12 or right-click → Inspect), then navigate to the Sources panel and expand the Content scripts tree on the left.
1// content.ts — identify yourself immediately for fast filtering
2console.log('[ContentScript] active on', location.href, 'at', Date.now());
3
4// Listen to messages from the service worker
5chrome.runtime.onMessage.addListener((msg) => {
6 console.log('[ContentScript] message received', msg);
7});
Execution context: Content script isolated world. The script has access to the extension’s chrome.* APIs and the host page’s DOM but not to the page’s JavaScript variables. When you set a breakpoint inside Sources → Content scripts, Chrome pauses in the isolated world — the page’s own scripts continue executing. On Firefox, content scripts appear under the Debugger panel in the same way; on Safari, they appear under the Develop menu’s Web Inspector.*
A common debugging trap: if you use window.addEventListener inside a content script to communicate with page scripts via custom events, that DOM boundary is visible in DevTools — page-world scripts appear in the main Sources tree while the isolated-world content script appears in the Content scripts subtree.
Extension pages (popup, options page, new-tab override) each get their own DevTools instance. The popup is the trickier case because it closes when it loses focus, taking its DevTools window with it.
For the popup: right-click the extension icon in the toolbar and choose Inspect popup (Chrome). This pins the popup open and opens DevTools attached to its page context simultaneously. Alternatively, open the popup HTML directly at its chrome-extension:// URL and use normal F12 DevTools.
For the options page: navigate to chrome://extensions, click the extension’s Details button, then click Extension options. The options page opens as a tab — press F12 to open standard DevTools.
1// popup.ts — always read storage async; never assume the worker has already written
2document.addEventListener('DOMContentLoaded', async () => {
3 console.log('[Popup] DOM ready, reading storage');
4 const { theme } = await chrome.storage.local.get('theme');
5 console.log('[Popup] theme =', theme);
6 document.body.dataset.theme = theme ?? 'light';
7});
Execution context: Extension page (popup HTML). The popup’s JS context is created fresh every time the popup opens and destroyed when it closes. Any in-memory state is lost on close — always persist to chrome.storage before the user dismisses the popup. The chrome.* APIs are fully available here without any permission bridge.*
Without source maps, every error stack trace points at your bundler’s output, making line numbers useless. For Vite and Webpack the configuration is a one-liner:
1// vite.config.ts
2import { defineConfig } from 'vite';
3
4export default defineConfig({
5 build: {
6 sourcemap: true, // emits .js.map files alongside output
7 rollupOptions: {
8 input: {
9 background: 'src/background.ts',
10 content: 'src/content.ts',
11 popup: 'src/popup/index.ts',
12 },
13 },
14 },
15});
Execution context: Build tool configuration (Node.js). Setting sourcemap: true emits inline or external source maps that Chrome’s DevTools loads automatically when the extension is loaded unpacked. Do not ship source maps in production CRX packages — they expose your full source code to anyone who downloads the extension. Use a build flag to toggle sourcemap: false for release builds.*
Once source maps are active, breakpoints set in your TypeScript source files in DevTools are respected — Chrome resolves them through the map file and pauses at the correct original line.
The Errors panel on chrome://extensions is the first place to check when an extension fails silently. It surfaces errors that occur before any context is alive — manifest parsing failures, missing file references, CSP violations during load, and uncaught exceptions at the top level of the service worker before any listener is registered.
Click Errors on the extension’s card. Each entry shows the source URL (e.g., chrome-extension://<id>/background.js), the line number (matching your source map if active), and the full error message. The Clear all button resets the count without reloading the extension.
The Errors panel captures errors in all extension contexts — service worker, content scripts, extension pages — not just the background. It is especially useful for content script CSP violations on specific host pages, which do not appear in the SW DevTools console at all.
For a focused deep-dive on service worker startup failures specifically, see debugging a service worker that won’t start.
Because each context has its own console, a unified logging approach routes all debug output through a single channel — the service worker console — using chrome.runtime.sendMessage:
1// shared/debug-log.ts — imported by popup, content scripts and options page
2export function remoteLog(level: 'log' | 'warn' | 'error', ...args: unknown[]) {
3 chrome.runtime.sendMessage({ type: '__DEBUG_LOG__', level, args }).catch(() => {
4 // Fallback if the service worker is not yet running
5 console[level]('[remote-log fallback]', ...args);
6 });
7}
1// background.ts — receives and re-emits from all contexts
2chrome.runtime.onMessage.addListener((msg) => {
3 if (msg.type !== '__DEBUG_LOG__') return;
4 console[msg.level as 'log' | 'warn' | 'error']('[from context]', ...msg.args);
5});
Execution context: The sender runs in the popup, content script, or options page. The receiver runs in the service worker. This pattern centralises all debug output in the SW DevTools console, which persists longer than the popup or options page context. Disable or tree-shake the remoteLog calls in production builds — the extra IPC adds latency to every logged call.*
chrome://extensions Inspect link; the new session resumes with a fresh worker.chrome-extension:// URL approach for stable popup debugging sessions.eval() in extension pages or service workers: the default extension CSP blocks it. debugger; statements work as a replacement for inline evaluation.chrome://extensions Errors panel does not persist across browser restarts: errors are cleared when Chrome closes.chrome://extensions, Inspect link, right-click Inspect popup) are Chrome-specific.about:debugging#/runtime/this-firefox. Click Inspect next to your extension. The resulting DevTools panel includes a combined console that shows output from all extension contexts simultaneously, which is more convenient than Chrome’s per-context model.await pitfalls.