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
How one misspelled column name becomes NaN with nothing reported Four stages of the repro, left to right: the request carries the misspelled column name; header.indexOf returns minus one; splitting a row at a negative index yields undefined; and Number of undefined produces NaN. A band underneath lists what neither browser reports — no exception, no unhandled rejection, no console warning and no failed request — identically in V8 and SpiderMonkey. One misspelled column name, four silent hops 1 · The request column: 'ammount' never validated cloned to the worker as-is 2 · The lookup header.indexOf(col) returns -1 'not found' is a number 3 · The read row.split(',')[idx] undefined negative index, missing key 4 · The maths total += Number(cell) NaN NaN spreads, never throws What neither browser reports no exception · no unhandled rejection · no console warning · no failed request — identical in V8 and SpiderMonkey
Every hop is a legal operation, so nothing surfaces: the value degrades quietly from a typo to 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.

The same five debugging steps, performed in Chrome and in Firefox Five rows, each naming a step in the middle column with the Chrome action on the left and the Firefox action on the right: find the worker in the Threads pane versus the Workers heading; pause on the first message with Script First Statement versus the Worker message breakpoint; read idx from a hover tooltip in both; confirm the realm with a sticky picker versus one that resets to Top; and take the measurement on the Timings track versus a marker on the worker's own lane. Chrome DevTools The step you are on Firefox DevTools 1 · Find the worker make it the debug target Sources → Threads pane the worker sits under the page scripts Debugger → Sources → Workers listed by script URL 2 · Pause on message 1 before any state exists Event Listener Breakpoints Script → Script First Statement Event Listener Breakpoints Worker → message 3 · Read idx one value, the whole bug Hover the name in Sources tooltip expands the object contents Hover the name in Debugger tooltip prefixes the type: Array(3) 4 · Confirm the realm before you type anything Picker stays on worker.js sticky across panels and reloads Picker resets to Top re-select it after every restart 5 · Take the measure read csv-parse, not a feeling Performance → Timings track beside frames and rendering lanes Marker on the worker's lane plus a shareable permalink Steps 1 to 3 are effectively identical — the toolchains only part company at steps 4 and 5.
The walkthrough side by side. The thicker borders on the last two rows mark where the browsers stop being interchangeable: the console context picker behaves differently, and the two profilers answer different questions.

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.

What each browser's message event-listener breakpoint actually catches A matrix of three message sources against two breakpoints. A worker onmessage delivery pauses in both browsers, which is the wanted pause. A BroadcastChannel message and a MessagePort message both pause Chrome's global Message breakpoint but are ignored by Firefox's Worker category. A band underneath shows that one postMessage into a four-worker pool costs five Chrome pauses against one Firefox pause. Event that fires in your app Chrome · Message Firefox · Worker → message worker.onmessage the pause you actually wanted Pauses correct — this is the one Pauses correct — this is the one BroadcastChannel 'message' every tab-to-tab sync notice Pauses noise — unrelated channel Ignored outside the Worker category MessagePort 'message' every pool idle notification Pauses noise — unrelated port Ignored outside the Worker category One postMessage into a four-worker pool each square is a pause you must step past Chrome Firefox 5 pauses 1 pause
Chrome's Message breakpoint is a whole-realm hook, so every port and channel delivery stops the debugger too; Firefox's Worker category stops only on worker messages. In a fan-out topology that is the difference between one pause and one per channel.

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.

The five scope layers of a paused worker, labelled by each browser Five stacked layers of the paused worker, from the current block down to the worker global. Chrome's label is shown on the left and Firefox's on the right. The first four layers carry identical names in both browsers — Block, Local, Closure and Module — while the bottom layer, the worker global reached through self, is named Global by Chrome and Worker by Firefox. Chrome label Scope layer in the paused worker Firefox label Block The current block let and const declared inside the loop body Block Local The current function locals of the onmessage handler — idx, total, lines Local Closure Captured outer variables anything the handler closes over Closure Module ES module bindings top-level imports and constants of worker.ts Module Global The worker global — self postMessage and importScripts; no document, no window Worker Same five layers in the same order — only the bottom label differs, and Firefox's names the realm.
Four of the five rows are pure relabelling. The fifth is a genuine hint: Chrome's Global mirrors how it names 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
What the console context picker does across a reload in each browser Two lanes across three moments. After picking worker.js in the console, reloading the page and then evaluating typeof document: Chrome keeps the picker on worker.js and answers undefined, still inside the worker realm; Firefox resets the picker to Top without warning and answers object, meaning the expression ran on the page instead. Pick worker.js in the console Reload — the worker respawns Evaluate typeof document Chrome sticky picker picker → worker.js context locked in picker → worker.js survives the reload "undefined" still in the worker realm Firefox picker resets picker → worker.js context selected picker → Top reset, with no warning "object" you are back on the page Nothing errors when the realm is wrong — page globals simply answer instead, so check the realm, not the output.
The failure mode is not that Firefox resets the picker, it is that the reset is silent. Re-running 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 .json only; 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.com and 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.mark entries pin to the worker’s lane, letting you attribute samples to named regions such as the csv-parse measure in the repro.
How each profiler lays out the worker thread Chrome's Performance panel stacks the worker lane beside the main, compositor and GPU lanes on one shared time axis, with a frames strip underneath showing the long frame lining up with the worker spike, and exports only a JSON file. The Firefox Profiler isolates the worker thread with Focus on thread, splits its samples into JavaScript, native and garbage-collection colours, and uploads to a shareable profiler.firefox.com permalink. Chrome · Performance panel every thread on one shared time axis Main Compositor Worker (worker.js) GPU Frames The worker spike and the long frame line up no correlation work: one axis, one recording Export: a .json file — no hosted link Firefox Profiler one thread isolated, samples split by kind Parent process Main Thread Worker: worker.ts GPU process Sample kinds JavaScript native GC Focus on thread hides every other lane the csv-parse marker pins to this lane Upload → profiler.firefox.com permalink
Chrome answers did the worker cost me a frame? by putting both on one axis. Firefox answers where did the worker's time go? by removing everything else and colouring garbage collection apart from JavaScript — and hands the result over as a link.

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.

Chrome's Application panel and Firefox's about:debugging, control for control The same five service-worker controls aligned across both browsers. Update, unregister, inspecting the worker in its own DevTools window and simulating push and sync exist on both sides, with Firefox lacking the push and sync simulation. Only Chrome offers a bypass-for-network checkbox, and only Firefox lists every origin's registration in one default view. Chrome · Application panel scoped to the current page and origin Firefox · about:debugging a browser-level registry, every origin Update / Unregister buttons on every registration row Force update / Unregister the same two controls, same place Inspect in its own window via chrome://serviceworker-internals Inspect in its own window one button; a full DevTools instance Simulate push and sync Push and Sync buttons in the panel Simulate push and sync no equivalent control Bypass for network checkbox — interception off, registration kept Bypass for network nothing — unregister, or fall through in fetch Every origin in one list internals page, outside DevTools Every origin in one list this is the default view Aligned rows expose the two real gaps: only Chrome can bypass interception, only Firefox lists every origin at once.
Everything above the fourth row is the same job with different button labels. The last two rows are the ones that decide which browser you open: the bypass checkbox lives only in Chrome, the cross-origin registry only in Firefox.

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.

Routing a worker symptom to the browser that answers it fastest Seven symptoms route to three answers. Residual UI jank, reload-heavy console iteration and comparing a service worker against raw network all route to Chrome. Unexplained worker CPU or garbage-collection time, a profile that must reach a colleague and pausing only on worker messages all route to Firefox. A bug that reproduces in one engine but not the other routes to opening both browsers side by side. Symptom you are chasing Open this one first UI still janks after the move worker finished, frames still dropped Reload-heavy console iteration you re-select the worker every cycle Service worker vs raw network A/B the fetch handler against the wire Unexplained worker CPU or GC the call tree is buried in render noise The profile must reach a colleague no JSON attachments, please Pause only on worker messages several channels are in flight at once Reproduces in one engine only same code, different received object Chrome DevTools one timeline for worker and frames console context stays pinned Bypass for network, plus storage Firefox DevTools Focus on thread kills render noise GC samples coloured separately a shareable profiler.firefox.com link Both, side by side pause on the same postMessage
Pick per symptom rather than per preference. Only the last row needs both browsers — a bug that lives in one engine and not the other is nearly always a structured-clone or header difference, not a logic error.

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.

COOP / COEP required before either browser shows a SharedArrayBuffer

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.

Four cross-browser worker-debugging traps and the one-line check for each Four panels. First, Firefox's console context resets to Top silently, so re-check typeof document after every reload. Second, Chrome's Message breakpoint is global and also catches BroadcastChannel and MessagePort traffic, so use a conditional line breakpoint or switch to Firefox. Third, a blob worker gets a fresh URL on every reload and loses its breakpoints, which a sourceURL comment fixes. Fourth, a SharedArrayBuffer missing from one scope pane is a COOP and COEP header problem, checked with self.crossOriginIsolated in both realms. 1 · The picker resets without warning Firefox snaps the console back to Top when the worker dies. Worker-local names then read as undefined on the page. typeof document → "undefined" = worker, "object" = page Fix: re-check the realm after every reload. 2 · Chrome's Message breakpoint is global It also fires for BroadcastChannel and MessagePort traffic. In a pool, one action becomes a pause per idle notice. worker · BroadcastChannel · MessagePort — all caught Fix: use a conditional line breakpoint — or Firefox. 3 · A blob worker loses its breakpoints createObjectURL mints a fresh blob: URL on every reload. Both debuggers key breakpoints by URL, so yours is orphaned. //# sourceURL=worker-inline.js — a stable pseudo-file Fix: name the blob source; breakpoints then survive. 4 · A missing SharedArrayBuffer is a header bug Both scope panes show it with byteLength — but only when the top-level document is cross-origin isolated. self.crossOriginIsolated === true in BOTH realms Fix: audit COOP and COEP before blaming the tooling.
Each trap has the same shape: the tooling behaves correctly and reports nothing, so the only defence is a cheap check you run by reflex. All four checks fit on one line.

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.

The measurement cost of attaching each browser's tooling Three bars for the same 100,000-row parse. With DevTools closed it measures 55 milliseconds, the number worth reporting. With the Chrome debugger attached it measures 58 to 63 milliseconds because debugger hooks disable some optimisations. With the Firefox toolbar profiler alone it measures 55 to 57 milliseconds, because the sampler attaches without installing debugger hooks. The same 55 ms parse, measured three ways DevTools closed the number you report 55 ms Chrome debugger attached hooks disable optimisations 58–63 ms Firefox toolbar profiler sampler on, debugger off 55–57 ms 0 20 ms 40 ms 60 ms true worker cost instrumentation overhead Take the baseline with the tools closed and treat every recorded absolute number as an upper bound.
A 10% regression threshold cannot survive a debugger-attached measurement. Firefox's toolbar profiler is the closest either browser gets to a clean number, because the sampler attaches without the debugger hooks.

Frequently Asked Questions

Which browser should I open first when a dedicated worker returns a wrong value?
Either — the two debuggers reach parity on breakpoints, stepping, scope inspection and console context, so the first pause costs the same in both. The split appears afterwards. If the next question is why is the UI still janky, use Chrome: its Performance panel draws the worker lane in the same timeline as frames and rendering, so a worker spike and a dropped frame line up visually. If the next question is where is the worker burning CPU or how do I hand this to a colleague, use Firefox: the Firefox Profiler isolates a single thread, colours garbage-collection samples separately from JavaScript samples, and produces a shareable profiler.firefox.com permalink instead of a JSON file.
Does Safari have a console context picker for workers?
No. As of Safari 17, Web Inspector does not offer a console context picker that switches evaluation into a worker thread. You can set breakpoints in worker scripts and read the Scopes pane while paused, but you cannot freely evaluate expressions in the worker realm from the console. Debug interactively in Chrome or Firefox, then confirm the fix in Safari with breakpoints and logging only.

See also