Comparing Chrome and Firefox Worker Tooling
One worker, one bug, two debuggers: this page runs a single reproducible dedicated-worker failure through Chrome DevTools and Firefox DevTools and records exactly where the two toolchains diverge. It belongs to the Firefox Worker Debugging walkthrough inside the Debugging, Profiling & Production Optimization reference, and it assumes you already know the single-browser workflows described there and in Chrome DevTools Worker Debugging. The short version: the debuggers are near-identical, the profilers are not, and the service-worker surfaces are barely comparable at all.
A Repro You Can Load in Both Browsers
Every claim below was checked against this pair of files. The worker parses a CSV column and returns a sum; the driver passes a misspelled column name, so the sum comes back NaN with no exception, no console warning and no failed request — the class of bug that forces you into a debugger instead of a log line.
// worker.ts — loaded unmodified in both browsers, no engine-specific branches
interface ParseRequest { csv: string; column: string }
interface ParseReply { total: number; rows: number }
self.onmessage = ({ data }: MessageEvent<ParseRequest>) => {
performance.mark('parse-start'); // becomes a marker in BOTH profilers
const lines = data.csv.split('\n');
const header = lines[0].split(',');
const idx = header.indexOf(data.column); // -1 when the column name is misspelled
let total = 0;
for (let i = 1; i < lines.length; i++) {
const cell = lines[i].split(',')[idx]; // undefined once idx === -1
total += Number(cell); // Number(undefined) is NaN — silently
}
performance.mark('parse-end');
performance.measure('csv-parse', 'parse-start', 'parse-end');
const reply: ParseReply = { total, rows: lines.length - 1 };
(self as unknown as DedicatedWorkerGlobalScope).postMessage(reply);
};
// main.ts — driver page
const worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' });
worker.onmessage = ({ data }: MessageEvent<ParseReply>) => {
console.log(`total=${data.total} rows=${data.rows}`); // total=NaN on the broken run
};
const csv = 'id,amount\n' +
Array.from({ length: 100_000 }, (_, i) => `${i},${i * 3}`).join('\n');
worker.postMessage({ csv, column: 'ammount' }); // typo reproduces the NaN
NaN. That is why this repro can only be settled in a debugger — and why it behaves the same in both engines.Walkthrough: The Same Bug Under Both Debuggers
Step 1 — Get the worker into the thread list. In Chrome the worker appears in Sources under the Threads pane, and clicking it makes that isolate the active debug target. In Firefox it appears in the Debugger’s source list under a Workers heading labelled by script URL. Both browsers require the worker to be alive: open DevTools before the line that runs new Worker(...), or you will be staring at a thread list that is legitimately empty.
Step 2 — Pause on the first message, not on a line. Guessing a line number is the slow path. Chrome: Event Listener Breakpoints → Script → Script First Statement, then reload. Firefox: Event Listener Breakpoints → Worker → message. Firefox’s category is narrower and therefore quieter — Chrome’s Message breakpoint also fires for BroadcastChannel and MessagePort deliveries, which matters as soon as more than one channel is in flight (the topologies catalogued in Message Passing Strategies all hit this).
Step 3 — Read idx at the top of the loop. That single value is the whole bug: header.indexOf('ammount') returns -1, lines[i].split(',')[-1] is undefined, and Number(undefined) is NaN. Both browsers print the value inline beside the source line on hover. The tooltips differ slightly in shape — Firefox prefixes object values with their type (Array(3)) where Chrome expands the contents — but for a primitive like -1 they are identical.
Step 4 — Confirm which realm you are actually in. Before trusting anything typed into the console, switch the context picker to the worker and evaluate self.constructor.name. Skipping this step is the single most common cross-browser mistake, for the reason set out in the gotchas below.
Step 5 — Take the measurement. The performance.measure('csv-parse', …) call surfaces in both timelines: as a Timings-track entry in Chrome’s Performance panel, and as a marker pinned to the worker’s own lane in the Firefox Profiler. That named region is what lets you report the parse cost 55 ms rather than the round-trip felt slow.
Feature-by-Feature Comparison
| Feature | Chrome DevTools | Firefox DevTools |
|---|---|---|
| Worker thread in Sources/Debugger | Sources panel → Threads pane, workers listed under page scripts (Chrome 38+) | Debugger → Sources → Workers heading (Firefox 99+) |
| Breakpoints in worker scripts | Full support: line, conditional, logpoint, event listener (Chrome 38+) | Full support: line, conditional, log breakpoint, event listener (Firefox 46+) |
| Step over / into / out in worker | Yes, step controls operate on the worker context (Chrome 38+) | Yes, identical step controls (Firefox 46+) |
| Scope inspection while paused | Scope pane: Local, Closure, Module, Global (Chrome 38+) | Scopes pane: Block, Local, Closure, Module, Worker (Firefox 56+) |
| Inline variable value tooltips | Yes — hover any variable name in source (Chrome 72+) | Yes — hover tooltip, prefixed with the value’s type (Firefox 67+) |
| Console context picker for workers | Yes — selector in the Console toolbar, persists across panels (Chrome 72+) | Yes — picker dropdown, resets to Top on worker restart (Firefox 56+) |
| Watch expressions in worker context | Yes — Watch section in the Sources panel (Chrome 38+) | Yes — Watch Expressions section (Firefox 56+) |
| Worker thread in Performance/Profiler | Worker lane in the same flame chart as rendering (Chrome 70+) | Dedicated thread lane in the Firefox Profiler (Firefox 55+) |
| Per-thread CPU filtering | Filter by thread in the Performance panel (Chrome 80+) | Focus on thread in the Firefox Profiler timeline (Firefox 64+) |
| Garbage-collection sample breakdown | GC appears inside the flame chart, not colour-separated | JavaScript, native and GC samples colour-coded separately |
performance.mark / measure in the profiler |
Entries on the Timings track (Chrome 75+) | Markers pinned to the worker’s own lane (Firefox 52+) |
| Service worker debugging | Application panel → Service Workers; chrome://serviceworker-internals/ |
about:debugging → This Firefox → Service Workers |
| Service worker force update | Update button in the Application panel | Force update button in about:debugging |
| Bypass service worker for network | Yes — Bypass for network checkbox | No equivalent; unregister or pass through in the fetch handler |
| Service worker network intercept view | Network panel marks the initiator (service worker) |
Network panel marks intercepted requests SW |
Blob worker //# sourceURL support |
Yes (Chrome 38+) | Yes (Firefox 48+) |
| Source maps in worker scripts | Yes (Chrome 38+) | Yes (Firefox 48+) |
Worker-to-worker MessagePort inspection |
Not directly visible; breakpoint both ends | Not directly visible; breakpoint both ends |
SharedArrayBuffer in the scope pane |
Shown as SharedArrayBuffer with byteLength (Chrome 92+) |
Shown as SharedArrayBuffer with byteLength (Firefox 76+) |
| Offline / shareable profile | JSON export only | profiler.firefox.com permalink, plus a profile diff view |
| Remote debugging (Android) | chrome://inspect — full worker support |
about:debugging — full worker support |
Breakpoints and Step Debugging
The two debuggers reach parity on fundamentals, so every remaining difference is ergonomic.
Conditional breakpoints open as an inline overlay in both browsers; Chrome syntax-highlights the condition editor and Firefox does not, which only matters for long expressions. Both evaluate the condition in the correct worker scope, so i > 90000 && Number.isNaN(total) behaves identically against the repro above.
Logpoints are called Logpoints in Chrome and Log breakpoints in Firefox. Both print without pausing and both interpolate with {expression} syntax. Neither is a real console call, so neither appears in a HAR export — do not treat them as an audit trail.
Worker event listener breakpoints are where Firefox pulls ahead. Its Event Listener Breakpoints panel carries a Worker category with a dedicated message checkbox that pauses on incoming worker messages and nothing else. Chrome’s nearest equivalent, Event Listener Breakpoints → Message, is global: it also catches BroadcastChannel and MessagePort deliveries, so in a fan-out topology you step through several irrelevant pauses before reaching the one you wanted.
Scope Inspection
Both browsers expose the same conceptual layers while paused; only the labels differ.
| Scope layer | Chrome label | Firefox label |
|---|---|---|
Current block (let / const) |
Block | Block |
| Current function locals | Local | Local |
| Captured outer variables | Closure | Closure |
| ES module bindings | Module | Module |
Worker global (self) |
Global | Worker |
The last row is the only meaningful difference. Chrome labels the worker global Global, mirroring how it labels window on the main thread; Firefox labels it Worker to make clear you are looking at a DedicatedWorkerGlobalScope rather than a browser window. Firefox’s label is the more useful of the two when you are new to a codebase and need to know instantly whether document should exist here. The per-layer reading technique — including editing a paused value in place — is covered in Inspecting Worker Scopes in Firefox DevTools.
window, while Firefox's Worker tells you at a glance that document was never going to be here.Console Context Switching
Chrome 72+ and Firefox 56+ both put a context picker in the Console toolbar that moves evaluation into a running or paused worker thread.
Chrome shows a dropdown labelled with the current context (usually the page URL), listing worker threads by worker URL. The selection is sticky for the DevTools session: it survives panel switches and stays pointed at the worker.
Firefox shows “Top” by default and lists worker threads by script name. It falls back to “Top” whenever the selected worker terminates, so a short-lived worker recreated on every reload has to be re-selected each cycle.
Practical implication: for reload-heavy iteration, Chrome’s stickiness removes a repeated click and, more importantly, removes a silent failure mode — a reset picker means your next expression runs on the page instead of in the worker and quietly returns page values. For a single deep dive on a long-lived worker, the two are equivalent.
// Paste into the worker console context in each browser to confirm the realm:
self.constructor.name // Chrome: "DedicatedWorkerGlobalScope" | Firefox: same
typeof window // Both: "undefined" (an "object" here means you are on Top)
typeof document // Both: "undefined"
typeof importScripts // Both: "function" in a classic worker, "undefined" in a module worker
performance.timeOrigin // Both: the timestamp at which this worker was created
typeof document after every reload is the cheapest way to notice before you spend ten minutes on a value that never existed in that realm.Profiling Worker Threads
This is where the two toolchains genuinely diverge, and it is why most engineers end up with both browsers open.
Chrome Performance panel:
- Worker threads render as horizontal lanes labelled
Worker (worker.js)in the flame chart. - Those lanes sit beside the Main thread, Compositor and GPU lanes, sharing one time axis with the frame markers.
- Because worker samples and rendering share that axis, a worker CPU spike that lines up with a long frame is visible at a glance — no correlation work required.
- Recordings export as
.jsononly; there is no hosted permalink. - Performance Insights flags generic bottlenecks but still does not call out worker-specific problems such as serialization-dominated tasks, which need the technique in postMessage Bottleneck Analysis.
Firefox Profiler:
- Each worker gets its own lane, labelled by script URL.
- Recordings upload to
profiler.firefox.comand produce a shareable permalink — the fastest way to hand a flame chart to a colleague without attaching a file. - Call Tree and Flame Graph support Focus on thread, which hides every other lane so you read the worker’s stack with zero rendering noise.
- Samples are colour-coded as JavaScript, native and GC, so worker memory pressure shows up as a visibly distinct band instead of hiding inside the flame chart.
performance.markentries pin to the worker’s lane, letting you attribute samples to named regions such as thecsv-parsemeasure in the repro.
Service Worker Tooling
Service worker surfaces are the least comparable part of the two toolchains: Chrome puts them inside page DevTools, Firefox puts them in a browser-level registry.
Chrome — Application panel:
- Application → Service Workers lists every registration for the current origin.
- Controls: Update, Unregister, Push (simulate a push event), Sync (simulate a background sync).
- The Bypass for network checkbox disables interception for the current tab without unregistering — the fastest way to A/B a fetch handler against raw network behaviour.
chrome://serviceworker-internals/lists registrations across all origins with start/stop controls and a direct link into each worker’s own DevTools window.
Firefox — about:debugging:
about:debugging→ This Firefox → Service Workers lists registrations across all origins in one view.- Controls: Inspect (opens a DevTools window scoped to the worker), Force update, Unregister.
- There is no bypass equivalent: to test raw network behaviour you unregister, or make the handler fall through with
event.respondWith(fetch(event.request)). - The Inspect window is a complete DevTools instance — Debugger, Console, Storage and Network panels, all scoped to the worker rather than to the page.
Practical guidance: toggling between “worker active” and “raw network” is a Chrome job because of the bypass checkbox. Auditing which origins have stale registrations installed — the usual cause of a fix that refuses to ship — is a Firefox job, because about:debugging shows them all at once. If your service worker is doing computation rather than caching, the trade-offs in Dedicated vs Service Workers for CPU Tasks apply before either panel becomes relevant.
Choosing a Browser by Bug Class
Rather than a general preference, pick per symptom:
| Symptom you are chasing | Open | Why |
|---|---|---|
| Worker finished, UI still janks | Chrome | Worker lane and frame markers share one timeline, so spikes and dropped frames line up without correlation work |
| Worker CPU time is unexplained | Firefox | Focus on thread removes all rendering noise from the Call Tree |
| Memory pressure suspected in the worker | Firefox | GC samples are colour-separated from JavaScript samples |
| Profile needs to go to a colleague | Firefox | profiler.firefox.com permalink instead of a JSON attachment |
| Many channels, need to pause only on worker messages | Firefox | The Worker message event listener breakpoint is scoped; Chrome’s is global |
| Reload-heavy iteration in the worker console | Chrome | The context picker stays pinned to the worker across reloads and panels |
| Service worker vs raw network comparison | Chrome | Bypass for network toggles interception without unregistering |
| Stale registrations across several origins | Firefox | about:debugging is a cross-origin registry |
| Cache Storage / IndexedDB inspected alongside the worker | Chrome | The Application panel unifies storage surfaces with the registration list |
And sometimes: both. A bug that reproduces in one engine and not the other is usually an engine or header difference rather than a logic error. Structured-clone edge cases are the classic example — cyclic graphs, Error subclass properties and some typed-array views clone with subtly different fidelity in V8 and SpiderMonkey, as detailed in Step-by-Step Guide to the Structured Clone Algorithm. Pausing both debuggers on the same postMessage call and comparing the two received objects side by side settles it in a minute.
Gotchas & Edge Cases
1. A reset context picker fails silently. When a Firefox worker terminates, the console context snaps back to “Top” — but the console does not warn you. Your next expression evaluates against the page, where worker-local variables are undefined rather than an error, and you spend ten minutes debugging a value that never existed in that realm. Always re-evaluate typeof document after a reload: "undefined" means the worker, "object" means you are back on the page.
2. Chrome’s message breakpoint is not worker-scoped. Event Listener Breakpoints → Message catches BroadcastChannel and MessagePort deliveries alongside worker messages. In a pool topology such as the one in Worker Pool Management, that means a pause per idle-notification message. Either use a conditional line breakpoint on the handler instead, or run this part of the session in Firefox.
3. Blob workers lose breakpoints across reloads. A worker created from URL.createObjectURL(new Blob([...])) gets a fresh blob: URL on every reload, and both browsers key breakpoints by URL, so the breakpoint is orphaned. Append //# sourceURL=worker-inline.js inside the blob source: both DevTools then list a stable pseudo-file that keeps its breakpoints. The same trick makes stack traces readable, which matters for Structured Error Serialization Across Threads.
4. A SharedArrayBuffer that exists in one browser and not the other is a header problem, not a tooling gap. Both scope panes render SharedArrayBuffer with its byteLength (Chrome 92+, Firefox 76+) — but only when the document is cross-origin isolated.
SharedArrayBuffer is unavailable unless the top-level document is served with Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp, with every subresource same-origin or explicitly opted in via CORP/CORS. Check self.crossOriginIsolated === true in both realms — the page and the worker — before blaming DevTools. Chrome reports the state under Application → Frames → top → Security & Isolation; in Firefox, evaluate the flag in the console context. A dev server that sets the headers and a staging proxy that strips them is the usual reason a buffer appears in one browser and not the other. See SharedArrayBuffer & Atomics for the memory model, and Debugging SharedArrayBuffer Cross-Origin Errors for the full header audit.
Performance Note: What the Tools Cost You
Keep one number in mind: with the Debugger panel active, both browsers add roughly 5–15% to worker CPU time, because the V8 and SpiderMonkey debugger hooks disable some optimisations while a debug target is attached. On the 100,000-row repro above, a 55 ms parse measured with DevTools closed reads as 58–63 ms with the debugger attached — enough to invalidate a regression threshold set at 10%.
The working rule: take the baseline with DevTools closed (log performance.measure results to the console or ship them to your telemetry endpoint), then open the tools only for the recording session and treat the recorded absolute numbers as an upper bound. Firefox lowers that overhead further — since Firefox 94 the profiler can be started from a toolbar button without opening DevTools at all, so you record with the sampler attached but no debugger hooks installed. That is the closest either browser gets to a clean measurement of real worker CPU time.