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.
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.
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.
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.
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:debuggingis 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
- 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.
- Load a minified build with external source maps and confirm the worker stack traces resolve to source lines.
- Run the storage helper in Safari and confirm it gives you what the missing panel would have.
- 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.
Related
- Debugging a service worker that won’t start — when there is no console to open.
- Debugging Extension Contexts — the guide this page belongs to.
- Fixing CSP violations in extension pages — the errors that hide in the wrong console.
- Background script support across browsers — what you are inspecting in each engine.