DevTools Differences Across Browsers

Where to inspect each MV3 extension context in Chrome, Firefox and Safari: reaching the worker, source maps, storage inspection, and the tooling gaps to plan around.

Published July 25, 2026 Updated July 25, 2026 8 min read
Table of Contents

Every engine exposes the same extension contexts and reaches them by a different route, and the routes are not discoverable — nothing in Chrome’s DevTools hints that the service worker console lives on the extensions page, and nothing in Firefox hints at the Browser Toolbox. Time lost to this is time lost repeatedly, on every engine, by everyone on the team. This page is part of Debugging Extension Contexts and is the lookup table.

Root cause: extension contexts are not tabs

DevTools is organised around tabs, and only two extension contexts are tab-shaped: content scripts, which run inside a page, and an options page opened in a tab. The service worker, the popup, the offscreen document and the side panel each have their own renderer with no tab to attach to, so each engine invented its own entry point.

How to reach each context, per engineThe entry point for the service worker, popup, content script, options page and offscreen document in Chrome, Firefox and Safari.ContextChrome 120+Firefox 121+Safari 17+Service workerExtensions page linkabout:debuggingDevelop menuPopupRight-click → InspectBrowser ToolboxDevelop menuContent scriptPage DevTools, context me…Page DevToolsPage DevToolsOptions pageOrdinary tab toolsOrdinary tab toolsOrdinary tab toolsOffscreen documentExtensions pageNot applicableNot applicableSide panelRight-click → InspectBrowser ToolboxNot applicable
Only the content-script row is the same everywhere — it is the one context that lives inside a tab.

The routes, spelled out

Worth keeping somewhere the whole team can find, because none of these is guessable.

 1Chrome / Edge
 2  Service worker      chrome://extensions → your extension → "service worker" link
 3  Offscreen document  chrome://extensions → your extension → "Inspect views"
 4  Popup               open the popup, right-click inside it → Inspect
 5  Content script      page DevTools → Console → context dropdown → your extension
 6  Storage             any extension page DevTools → Application → Storage → Extension storage
 7
 8Firefox
 9  Everything          about:debugging#/runtime/this-firefox → your extension → Inspect
10  Content script      page DevTools → Console (extension messages are labelled)
11  Popup, deeper       Browser Toolbox (enable in Settings → Advanced)
12  Storage             the same Inspect window → Storage → Extension Storage
13
14Safari
15  Enable first        Safari → Settings → Advanced → Show Develop menu,
16                      then Develop → Allow Unsigned Extensions (development builds)
17  Contexts            Develop → Web Extension Background Content, and per-page inspectors
18  Storage             no dedicated extension storage panel — log values instead

Execution context: A reference for developers, not code. The Safari step order matters: without allowing unsigned extensions, a development build will not load at all, and the failure presents as the extension simply being absent rather than as an error. The Chrome extension storage panel is comparatively recent and is by far the fastest way to see what a page actually wrote, which makes it worth the habit.

Source maps behave differently

A minified worker with a broken source map is nearly undebuggable, and each engine handles maps slightly differently for extension contexts.

 1// vite.config.js / esbuild — settings that produce debuggable extension bundles
 2export default {
 3  build: {
 4    sourcemap: true,          // external .map files; inline eval-based maps are CSP-blocked
 5    minify: false,            // for the development build only
 6    rollupOptions: {
 7      output: { format: "es", entryFileNames: "[name].js", chunkFileNames: "[name].js" },
 8    },
 9  },
10};

Execution context: Build configuration. External maps are mandatory rather than preferable: an eval-based source map is refused by the extension content security policy, and the resulting failure is reported as a script error rather than as a source map problem, which sends people looking in the wrong place. Stable chunk names matter too — a hashed chunk name changes on every build, and the map lookup follows it, but the manifest reference does not.

Why the console you have open is usually the wrong oneA single user action can produce log output in four separate consoles, one per context involved.User clicks in the popuppopup consoleMessage to the workerworker consoleScript injectedpage consolethree consoles for one interactionOffscreen worka fourth consoleCross-context log shimfunnel them into one
A missing log is far more often the wrong console than the wrong code.

Storage inspection, and what to do without it

Chrome and Firefox both show extension storage in a panel; Safari does not. A tiny inspection helper closes the gap and is faster than a panel anyway when you know the key.

What each engine's tooling gives youLayered view of extension debugging support, from universal page tools through per-engine background inspection to features only Chrome provides.Page DevToolscontent scripts, options pageidentical in all threeBackground inspectionworker or event page consoledifferent route per enginePopup and panel internalsinspect or Browser Toolboxawkward outside ChromeStorage panel + inspect viewsoffscreen documents, extension storageChrome only
Develop against the top of this stack and verify against the bottom — not the other way round.
 1// Paste into any extension context's console.
 2(async () => {
 3  const areas = ["local", "sync", "session"];
 4  for (const area of areas) {
 5    try {
 6      const all = await chrome.storage[area].get(null);
 7      console.group(`storage.${area}${Object.keys(all).length} key(s)`);
 8      console.table(Object.entries(all).map(([key, value]) => ({
 9        key,
10        type: Array.isArray(value) ? "array" : typeof value,
11        size: JSON.stringify(value).length,
12      })));
13      console.groupEnd();
14    } catch (error) {
15      console.warn(`storage.${area} unavailable here`, error.message);
16    }
17  }
18})();

Execution context: Any extension context’s console — the worker, the popup or an options page. get(null) returns everything in the area, and reporting the serialised size per key is what makes a quota problem obvious at a glance. The try/catch handles session from a content script, where it is unavailable by default.

A cross-context log shim closes most of the gap

Since no engine offers an aggregate console, the cheapest approximation is to forward tagged records to the background context and read one console instead of four. It costs a message per log line, so it belongs in development builds only.

 1// dev-log.js — imported only by the development build
 2const CONTEXT = location.pathname.includes("popup") ? "popup"
 3  : location.pathname.includes("panel") ? "panel"
 4  : typeof document === "undefined" ? "worker" : "content";
 5
 6export function log(...args) {
 7  if (CONTEXT === "worker") { console.log(`[${CONTEXT}]`, ...args); return; }
 8  console.log(`[${CONTEXT}]`, ...args);                 // still local, for context
 9  try {
10    void chrome.runtime.sendMessage({ __log: true, ctx: CONTEXT, args: args.map(String) });
11  } catch { /* worker unavailable — the local log is enough */ }
12}

Execution context: Imported by every surface in a development build; the worker prints the forwarded records alongside its own. Logging locally as well as forwarding is deliberate — if the worker is mid-eviction the message is lost, and losing a log line while debugging is worse than printing it twice. Stripping this module from the production build matters for more than size: each forwarded line is a message that wakes the worker, which is exactly the kind of avoidable cost the cold-start work exists to remove.

Reproducing an engine-specific bug

When a report only reproduces in one engine, the fastest triage is usually the console the reporter did not open. Ask for the background console output specifically, since that is the one no engine surfaces by default, and include the route for their browser in the issue template — a bug report with the worker log attached is often a bug report that answers itself.

Cross-browser variation

  • Chrome 120+: the richest tooling — a dedicated service worker console, an inspect-views list for offscreen documents, and an extension storage panel. Develop here.
  • Firefox 121+: about:debugging is one entry point for everything, which is easier to remember than Chrome’s several. The Browser Toolbox is needed for popup internals and must be enabled first.
  • Safari 17+: the Develop menu covers background content and pages; there is no extension storage panel and no offscreen documents to inspect. Expect to rely on logging.
  • All engines: each context has its own console and there is no aggregate view. A cross-context log shim is the closest thing to one.

Verification

  1. Produce a log line in each of the four contexts and find each one, in each engine. Time it — that number is what the lookup table above saves.
  2. Load a minified build with external source maps and confirm the worker stack traces resolve to source lines.
  3. Run the storage helper in Safari and confirm it gives you what the missing panel would have.
  4. Trigger an error in the offscreen document and confirm you can reach its console from the extensions page.

FAQ

Why is my console.log missing?

Almost certainly the wrong console. Each context logs to its own, and the service worker’s is the one most often overlooked because it is reached from the extensions page rather than from DevTools.

Can I see all extension logs in one place?

Not natively. A log shim that forwards tagged records to the worker gives you one console at the cost of a message per log line — useful during development, worth stripping from the shipped build.

Why do my breakpoints not bind in the service worker?

Usually an inline or eval-based source map that the content security policy refused. Switch to external maps; the bindings return immediately.

Why does reloading the extension not pick up my change?

Each engine caches the extension package differently. Chrome reloads everything from the extensions page button; Firefox reloads from about:debugging; Safari often needs the containing app rebuilt. Content scripts additionally need their host page reloaded, because an already-injected script keeps running until its page goes away — which is why a change that appears to have no effect frequently just needs a page refresh alongside the extension reload.

Does Safari support extension storage inspection?

Not with a panel. Use the console helper above, or add a debug page to the extension that renders storage contents.

Other Testing, Debugging & Performance Optimization Resources