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.
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.
Prerequisites checklist
- Developer mode enabled on
chrome://extensions— without it the Inspect link is hidden and the Errors panel is not accessible. - Source maps configured in your bundler output — without them every stack trace points at minified output with no useful line numbers.
- Extension loaded as unpacked — a CRX-installed extension from the Web Store runs in a read-only bundle and its source is not inspectable via DevTools.
- No
eval()orFunction()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.
1. Debugging the service worker
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.
2. Debugging content scripts
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.
3. Debugging popup and options pages
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.*
4. Source maps for readable stack traces
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.
5. The Errors panel
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.
6. Cross-context logging pattern
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.*
7. A method for bugs that span contexts
The hardest extension bugs involve more than one context: the popup sends a message, the worker handles it, a content script applies the result, and somewhere along the way something is lost. Debugging one context at a time rarely finds these, because each context looks correct in isolation. A systematic approach works better than stepping through code.
Start by writing down the path the data should take, context by context, including the storage writes and messages in between. Then instrument each hop with a log line that carries a shared correlation id — a short random string created where the action starts and passed along with every message.
1const cid = crypto.randomUUID().slice(0, 8);
2log.info("save:start", cid);
3await chrome.runtime.sendMessage({ type: "articles:save", cid, url });
4// in the worker handler: log.info("save:handled", msg.cid); … in the content script: log.info("save:applied", msg.cid)
Execution context: every context along the path, writing to the shared storage-backed log described in logging across contexts without losing messages. Filtering the combined log by the id shows exactly which hop the flow reached and where it stopped — no breakpoints, and no inspector keeping the worker artificially alive.
Once the failing hop is known, the question narrows to one context, and the tools in the sections above apply directly. Most cross-context bugs turn out to be one of three things: a listener registered too late to receive the waking message, a message sent to a context that no longer exists, or a storage write that one context made and another read before it completed.
8. Reproducing what users see
A bug that reproduces only on a user’s machine usually depends on state your development profile does not have: an older schema in storage, a revoked permission, a different set of open tabs, another extension competing for the same page. Reproduce state before reproducing steps.
A fresh browser profile with the user’s extension version, seeded with a storage fixture matching their schema, recreates most of it. Their diagnostics export — version, granted permissions, registered alarms and scripts, recent errors — supplies the rest, as described in diagnosing crashes from user reports. Only once the state matches is it worth stepping through the code, because only then is the code running under the same conditions that produced the failure.
9. Building debuggability into the extension
The fastest debugging session is the one where the extension already recorded what happened. A few small additions, made once, pay off in every later investigation.
Keep a capped, storage-backed log that every context writes to, tagged by context and carrying correlation ids for multi-step flows. Record the timestamp of the last successful run of each scheduled job. Persist uncaught errors from every context into the same place. And add a diagnostics view to the options page that shows all of it — version, granted permissions, registered alarms and content scripts, recent errors and log lines — with a button to copy it.
With those in place, the answer to most bug reports is already on the affected machine. Developers read it from their own profile after reproducing a problem with every inspector closed; users can paste it into a support request without opening DevTools at all. The investment is a few hundred lines of code, and it turns the extension’s hardest property — contexts that come and go without leaving a trace — into something that can be looked back on after the fact.
Keep the diagnostics view honest about privacy: counts instead of lists for granted hosts, extension paths instead of page URLs, and a visible preview before anything is copied. A debugging aid that leaks browsing data would undo the trust it is meant to build.
MV3 constraints to design around
- Service worker DevTools closes on worker eviction: the attached DevTools window becomes unresponsive after ~30 seconds of idle. Re-open it via the
chrome://extensionsInspect link; the new session resumes with a fresh worker. - Popup DevTools closes on popup dismiss: clicking outside the popup closes both. Use the
chrome-extension://URL approach for stable popup debugging sessions. - Content scripts cannot be paused without the host page’s DevTools: a breakpoint in a content script requires DevTools open on the specific host page tab where that script is running.
- No
eval()in extension pages or service workers: the default extension CSP blocks it.debugger;statements work as a replacement for inline evaluation. chrome://extensionsErrors panel does not persist across browser restarts: errors are cleared when Chrome closes.
Cross-browser notes
- Chrome — The reference platform. All paths described above (
chrome://extensions, Inspect link, right-click Inspect popup) are Chrome-specific. - Firefox — Extension debugging goes via
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. - Safari — Use the Develop menu → Web Extension Background Content to attach to the service worker equivalent. Content scripts are debuggable via Develop → Show Web Inspector on the host page. Safari requires the extension to have an explicit entitlement for debugging in production builds.
Further guides in this topic
The guides below go deeper into specific debugging extension contexts in mv3 problems that the sections above only touch on — each one starts from a concrete symptom and ends with a way to verify the fix.
- 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.
- Finding the Right DevTools Target for Each Context — Open the correct inspector for every MV3 context — service worker, popup, options, side panel, offscreen document, content script and DevTools panel — and avoid debugging the wrong one.
- Logging Across Contexts Without Losing Messages — Build one log stream for an MV3 extension — capturing logs from the worker, pages and content scripts, surviving worker eviction, levels and sampling, and exporting a diagnostic bundle.
- Reading Errors from the Extensions Page — Use the Errors view on chrome://extensions to find MV3 failures you never saw — manifest warnings, uncaught errors from every context, stack traces with context, and clearing noise.
Related
- Debugging a service worker that won’t start — diagnose registration failures, syntax errors, and top-level
awaitpitfalls. - End-to-end testing automation — complement manual debugging with automated Playwright tests.
- Service worker fundamentals — the lifecycle model that determines when worker DevTools is available.
- Content scripts & DOM injection — isolated world architecture that shapes content script debugging.
- Up to Testing, Debugging & Performance Optimization.