Firefox Worker Debugging

Firefox DevTools ships a first-class debugger for dedicated Web Workers: worker threads appear in the Debugger’s Sources list, breakpoints pause the worker without freezing the page, and every thread gets its own lane in the profiler — no extensions, no flags, no about:config edits. This guide is a specialisation of Debugging, Profiling & Production Optimization and covers the complete workflow: locating worker threads, setting breakpoints, reading scope, evaluating expressions in the worker’s global context, stepping through postMessage handlers, deciding whether a slow round-trip is compute or serialization, and profiling CPU usage per thread.

The Problem: A Thread You Cannot Print Your Way Out Of

The symptom that brings engineers here is specific. A page hands a 5 MB CSV (roughly 100,000 rows) to a worker for parsing and the UI is supposed to stay interactive. Instead the round-trip takes ~900 ms, one column comes back undefined for a handful of rows, and the frame right after the reply drops. Nothing appears in the page console, because the worker’s console.log output lands in the worker’s own context and an exception thrown inside onmessage never reaches window.onerror.

That leaves three questions that plain logging cannot answer:

  1. Where did the wrong value come from? You need to pause inside the worker with the offending row in scope, not reconstruct it from a serialized log line.
  2. Where did the 900 ms go? Structured-clone serialization on the main thread, deserialization on the worker thread, and the parse itself are three different costs with three different fixes — see postMessage Bottleneck Analysis for the taxonomy.
  3. Is the worker even the thread that stalled? A busy main thread and a busy worker look identical from the user’s chair, and only a per-thread profile separates them.

Firefox answers all three from one panel. The rest of this guide is the sequence that gets you there, followed by the failure modes that make the sequence appear not to work.

The same 912 ms round-trip seen by console logging and by the Firefox Debugger plus Profiler Top strip: the page console reports a single line, "worker returned in 912 ms", with no thread or phase attribution. Below it, two profiler lanes for the same interaction. The main-thread lane shows a structured-clone serialize block, a long stretch where the main thread is free, and a short reply-clone block at the end. The worker lane shows a deserialize-payload block followed by a long parseCSV block, with a breakpoint pause marked inside the parse block. What the page console reports one line — no thread, no phase breakdown worker returned in 912 ms What the Debugger and Profiler show for the same 912 ms Main thread postMessage serialize main thread free — the page keeps painting reply clone Worker thread deserialize payload parseCSV() — 100 000 rows 0 ms 912 ms breakpoint pause — the bad row is in Scopes The console gives you one number. The two lanes give you the four phases it is made of.
Console logging collapses the whole interaction into a single total. The Debugger and Profiler split the same 912 ms into serialize, deserialize, parse and reply — and let you stop inside the parse with the offending row still in scope.

Prerequisites

Before following these steps, confirm the following:

  • Firefox 110 or later (worker thread list in Debugger is stable from Firefox 99; profiler improvements landed in 110).
  • DevTools opened before the worker is created. Firefox registers threads as they spawn; workers that started before DevTools opened may not appear in the thread list.
  • If you are debugging a service worker, use about:debugging instead of the per-page Debugger panel (covered below in Step 1b).
  • For bundled workers (Vite, webpack), source maps must be present — either inline (//# sourceMappingURL=data:...) or as separate .map files served alongside the bundle. Without them, breakpoints land on minified output; Bundling Module Workers with Vite and webpack covers the config that emits them for worker chunks specifically.
  • If the worker touches SharedArrayBuffer, the document must already be cross-origin isolated: Cross-Origin-Opener-Policy: same-origin plus Cross-Origin-Embedder-Policy: require-corp, with self.crossOriginIsolated === true inside the worker. Without those headers the constructor is simply undefined and every breakpoint you set downstream of it is dead code.
  • The worker must still be alive when you look for it. A worker that runs once and calls self.close() disappears from the Sources list, so during a debugging session comment the close() out or gate it behind a flag.
Firefox version

The unified Debugger panel that shows workers in a thread list was stabilized in Firefox 99. If you are on an older version, update before following this guide. Firefox Developer Edition tracks the same features one release ahead and is a good choice for Worker debugging work.

The five preconditions a Firefox worker breakpoint depends on Five condition cards — Firefox 110 or later, DevTools opened before the worker spawns, the worker still alive, source maps emitted for the worker chunk, and cross-origin isolation when shared memory is used — all feeding arrows down into a single outcome bar reading "All five true, the breakpoint binds and the worker pauses". Five conditions, all required before a worker breakpoint can bind Firefox 110+ thread list stable since 99, profiler gains land in 110 DevTools first Firefox registers a thread as it spawns — never retroactively Worker still alive self.close() drops it from Sources; gate it behind a dev flag Source maps emitted for the worker chunk, or breakpoints land on minified code Cross-origin isolated COOP: same-origin + COEP: require-corp — only for shared memory All five true → the breakpoint binds and the worker pauses
Every "my breakpoint never fires" report traces back to one of these five conditions. Check them in order before assuming the Debugger is at fault.

Step-by-Step: Debugging a Dedicated Worker in Firefox

The seven steps below are the working sequence: attach, locate, break, inspect, step, evaluate, profile. Each one ends with the trade-off it imposes, because most of them cost you something — usually timing fidelity — in exchange for visibility.

Step 1 — Open DevTools and Navigate to the Debugger

Press F12 (Windows/Linux) or Cmd+Opt+I (macOS) to open DevTools. Click the Debugger tab in the toolbar.

If the page has not yet created any workers, the Sources sidebar will show only the main-document scripts. The worker entries appear dynamically as workers are spawned.

The reliable pattern during development is to keep worker creation lazy so you control when the thread appears, rather than racing DevTools against page load:

// main.ts — lazily create the worker so it spawns after DevTools has attached
let worker: Worker | null = null;

export function getWorker(): Worker {
  // The first call is what registers the thread with the Debugger.
  worker ??= new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' });
  return worker;
}

if (import.meta.env.DEV) {
  // Exposed only in dev builds: call getWorker() from the console once DevTools is open.
  (globalThis as Record<string, unknown>).getWorker = getWorker;
}
Trade-off: lazy creation changes startup timing

Deferring worker creation removes the DevTools race, but it also removes the warm-up you get from spawning the worker during page load — the first message now pays worker boot (typically 5–15 ms for a classic worker, more for a module worker that imports a dependency graph). Keep the lazy path behind a dev flag so production still spawns eagerly.

Step 1b — Service workers: use about:debugging

Dedicated workers live inside the per-page Debugger. Service workers are global browser processes not tied to a single tab, so Firefox exposes them through a separate surface:

  1. Type about:debugging in the address bar.
  2. Click This Firefox in the left sidebar.
  3. Under Service Workers, find your origin and click Inspect next to the active worker registration.

A new DevTools window opens showing the service worker’s Debugger, Console, and Storage panels in isolation. Changes to the service worker script are reflected after you click Force update and reload the controlled page.

Service worker lifecycle

A service worker only appears under about:debugging while it is active (installed and controlling at least one client). If it is in the waiting state, you must either close all controlled tabs or click skipWaiting in the registration panel before the new version activates and appears.

Step 2 — Locate the Worker in the Threads/Sources List

With DevTools open, trigger the code path that calls new Worker(...). Watch the Sources sidebar in the Debugger: a Workers heading appears, and your worker’s script URL is listed underneath it.

For module workers ({ type: 'module' }), the source tree expands to show the entry module and any imported modules. For classic script workers, a single file is listed.

Blob workers (new Worker(URL.createObjectURL(blob))) appear with a blob: URL. If you are generating the worker source at runtime, add a //# sourceURL=my-worker.js comment inside the blob string — Firefox uses this annotation as the display name in the Sources list.

// main.js — annotate blob workers for the Debugger
const code = `
  //# sourceURL=my-worker.js
  self.onmessage = ({ data }) => {
    const result = heavyTransform(data);
    self.postMessage(result);
  };
  function heavyTransform(v) { return v * 2; }
`;
const blob = new Blob([code], { type: 'application/javascript' });
const worker = new Worker(URL.createObjectURL(blob));
Blob worker annotation

Without a //# sourceURL annotation, Firefox displays blob workers as debugger eval code in stack traces and the Sources list. The annotation costs zero bytes at runtime and makes breakpoint targets human-readable.

Step 3 — Set Breakpoints in Worker Scripts

Click on the worker entry in the Sources sidebar to open its source in the editor pane. Set a breakpoint by clicking the line number gutter — a blue dot marker confirms the breakpoint is active.

Firefox supports the same breakpoint types for worker scripts as for main-thread scripts:

Breakpoint type How to set Use case
Line breakpoint Click line number Pause at a specific statement
Conditional breakpoint Right-click → Add condition Pause only when an expression is true
Log point Right-click → Add log Print to console without pausing
Event listener breakpoint Debugger → Event Listener Breakpoints Pause on message events

For postMessage handlers, the most practical approach is the Event listener breakpoint. In the right-side panel of the Debugger, expand Event Listener BreakpointsWorker and check message. Firefox will pause inside onmessage every time a message arrives at the worker, regardless of which line it lands on.

// worker.ts — structured message handler; set a breakpoint on line 6
interface ParseRequest {
  type: 'PARSE';
  csv: string;
}

self.onmessage = ({ data }: MessageEvent<ParseRequest>) => {
  if (data.type === 'PARSE') {           // ← breakpoint here
    const rows = parseCSV(data.csv);
    self.postMessage({ type: 'RESULT', rows });
  }
};

function parseCSV(raw: string): string[][] {
  return raw.split('\n').map(line => line.split(','));
}
Breakpoints and worker timing

Pausing a worker thread does not pause the main thread. The main thread continues executing, so postMessage calls accumulate in the worker's message queue while you inspect variables. When you resume, all queued messages process in order. This is useful for observing message batching behavior.

Step 4 — Inspect Worker Scope and Variables

When execution pauses inside a worker, the Scopes pane in the right sidebar populates with the full lexical environment at that stack frame. This is the core topic explored in depth in Inspecting Worker Scopes in Firefox DevTools.

The scope hierarchy shown is:

  1. Blocklet and const declarations in the current block.
  2. Local — function-level var declarations and parameters.
  3. Closure — variables captured from enclosing functions.
  4. Module — exported/imported bindings (module workers only).
  5. Worker — the worker’s global object (self), which contains onmessage, postMessage, importScripts, caches, and any global variables the worker script defines.

Expand any scope node to read live values. Primitive values show inline; objects and arrays expand to their properties. You can also hover over a variable name in the source editor to see a tooltip with its current value.

Firefox DevTools Debugger layout for a dedicated Web Worker Three-panel layout: left sidebar shows Threads and Sources list with a worker entry; center shows the worker source with a breakpoint; right panel shows the Scopes pane with worker variables. Sources ▾ This page main.js ▾ Workers worker.ts parseCSV.ts ▾ Event Listeners ☑ message ☐ error worker.ts 1 self.onmessage = ({ data }) => { 2 if (data.type === 'PARSE') { 3 const rows = parseCSV(data.csv); 4 self.postMessage({ rows }); 5 } 6 }; Paused on breakpoint — line 3 Scopes ▾ Local data: {type: "PARSE", …} rows: undefined ▸ Closure ▾ Worker self: DedicatedWorker… onmessage: function postMessage: function Console context: worker.ts (thread)
The Firefox Debugger showing a dedicated worker paused at a breakpoint: Sources list on the left with the worker entry highlighted, source code with a breakpoint marker in the center, and the Scopes pane on the right showing live worker variables.

Module-level state is the reason worker bugs so often look non-deterministic: a worker survives between messages, so anything cached at module scope keeps its value from the previous message. In the Scopes pane that state shows up under Module, not Local — and it is the first place to look when message n behaves differently from message 1.

// worker.ts — module-level state that persists across messages
let headerRow: string[] | null = null;   // ← visible under "Module" in Scopes
let processed = 0;                        // ← survives every onmessage call

self.onmessage = ({ data }: MessageEvent<{ csv: string; hasHeader: boolean }>) => {
  const lines = data.csv.split('\n');
  // Bug class: headerRow is only assigned on the first message, so a second
  // payload with a different schema is parsed against the stale header.
  if (data.hasHeader && headerRow === null) headerRow = lines.shift()!.split(',');
  processed += lines.length;
  self.postMessage({ headerRow, processed });
};
Trade-off: pausing vs watch expressions

Pausing gives you the complete lexical environment but destroys the timing you may be trying to measure. For state that changes across many messages, add the expression (e.g. processed) to the Watch expressions pane or use a log point instead — you keep near-real timing and still see each value, at the cost of not being able to expand nested objects interactively.

Step 5 — Step Through postMessage Handlers

Stepping controls in Firefox work identically for worker threads and main-thread scripts. The toolbar buttons are:

  • F8 / Resume — continue execution until the next breakpoint.
  • F10 / Step Over — execute the current line and pause on the next.
  • F11 / Step Into — enter the function called on the current line.
  • Shift+F11 / Step Out — run to the end of the current function and pause in the caller.

To trace the full round-trip of a postMessage call:

  1. Set a breakpoint in the main-thread code where worker.postMessage(...) is called.
  2. Set a second breakpoint inside the worker’s onmessage handler.
  3. Resume after the first breakpoint — the main thread delivers the message to the worker’s queue and the worker’s onmessage fires on the worker thread.
  4. Firefox automatically switches the Debugger’s active thread context to the worker when execution pauses there.

The active thread context is shown in a dropdown at the top of the Debugger panel. You can switch between main thread and worker threads manually to inspect both call stacks simultaneously.

// main.ts — round-trip tracing
const worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' });

worker.onmessage = ({ data }: MessageEvent<{ rows: string[][] }>) => {
  // Breakpoint A: inspect the returned rows here
  console.table(data.rows);
};

function triggerParse(csv: string): void {
  // Breakpoint B: confirm the message is sent with the right payload
  worker.postMessage({ type: 'PARSE', csv });
}
Thread context switching

When you pause in a worker, the main thread continues. If you then resume from the worker breakpoint, the main thread may have advanced past its own breakpoint. Use the thread-context dropdown to switch between threads and inspect each call stack independently.

Step 6 — Evaluate Expressions in the Worker Console Context

The DevTools Console panel has a context picker in its toolbar — a dropdown that defaults to Top (the main document). To evaluate expressions inside a running worker:

  1. Open the Console panel.
  2. Click the context picker dropdown.
  3. Select the worker by its script name or URL.

Any expression you type now runs in the worker’s global scope. You can read self.postMessage, call functions defined in the worker script, inspect module-level state, or run performance microbenchmarks inside the worker’s heap.

// Expressions typed in the worker console context:
self.constructor.name           // → "DedicatedWorkerGlobalScope"
typeof importScripts            // → "function"  (classic workers only)
typeof document                 // → "undefined" — DOM is not available
performance.now()               // → time in ms since worker started

The worker does not need to be paused for the console context switch to work. This makes it useful for reading live state without interrupting execution — for example, to observe how a counter variable changes over time.

Console context and paused workers

If the worker is paused at a breakpoint, console expressions evaluate in the current stack frame's scope — exactly like Chrome DevTools. If the worker is running, expressions evaluate at module scope. This distinction matters when you need to access a local variable: pause first, then evaluate.

Step 7 — Profile Worker Threads in the Firefox Profiler

The Firefox Profiler captures CPU samples from all threads, including workers. Each thread appears as a separate horizontal lane in the timeline.

To profile a worker:

  1. Open the Performance panel in DevTools.
  2. Click Start Recording.
  3. Trigger the workload (e.g., send a large payload to the worker).
  4. Click Stop Recording.
  5. The profiler opens as a separate tab at profiler.firefox.com.

In the profiler timeline, worker threads are labeled with their script URL. The Call Tree view aggregates samples by function; the Flame Graph shows the full call stack for each sample period.

Useful profiler features for workers:

  • Thread filter — click a thread lane header and press “Focus on thread” to hide all other threads. This isolates worker CPU samples from main-thread rendering noise.
  • Markers — add performance.mark() calls inside the worker to insert named markers in the profiler timeline. These appear as colored ticks on the worker’s lane.
  • CPU usage % per thread — hover over a thread lane to see the percentage of samples in each function category.
// worker.ts — instrumented with profiler markers
self.onmessage = ({ data }: MessageEvent<{ csv: string }>) => {
  performance.mark('parse-start');
  const rows = parseCSV(data.csv);
  performance.mark('parse-end');
  performance.measure('parse-duration', 'parse-start', 'parse-end');
  self.postMessage({ rows });
};
Realistic profiling numbers

Parsing a 5 MB CSV (≈100,000 rows) typically consumes 40–80 ms of worker CPU time on a mid-range laptop. The Firefox Profiler measures this at roughly 0.5–1 ms per 1,000 rows for a naive split-based parser. Replacing the parser with a compiled WebAssembly module typically reduces this to 0.05–0.1 ms per 1,000 rows.

Clone, Transfer or Share: Diagnosing the Data Path in Firefox

Once the worker’s own CPU time is measured, the remaining latency lives in how the payload crosses the thread boundary. Firefox gives you a distinct signal for each of the three strategies, and the debugging technique differs for all three.

Structured clone (the default). worker.postMessage(bigObject) deep-copies the payload; SpiderMonkey serializes on the sender’s thread and deserializes on the receiver’s. In the profiler this appears as main-thread time inside the postMessage call frame, followed by worker-thread time before your onmessage frame is even entered. If your breakpoint at the top of onmessage is hit 40 ms after postMessage returned, none of that 40 ms is your code — it is the clone. The mechanics of what can and cannot cross are covered in the Step-by-Step Guide to the Structured Clone Algorithm, and the measurement pattern in Measuring Structured Clone Cost with performance.now().

Transfer (zero-copy). Passing an ArrayBuffer in the transfer list moves ownership instead of copying it, as described in Transferable Objects & Zero-Copy. Firefox makes the effect visible in the Debugger: after a transfer the sender’s buffer is detached, and the Scopes pane shows byteLength: 0 for it. That is the check to run when a “the data is empty on the second send” bug appears — you are not looking at a race, you are looking at a buffer you already gave away.

SharedArrayBuffer. Both threads read the same memory, so there is nothing to observe on the wire at all — which is exactly why it is the hardest to debug. Firefox will only define SharedArrayBuffer when the document is cross-origin isolated, so the very first thing to confirm in the worker console context is self.crossOriginIsolated. Coordination bugs then show up as stalled Atomics.wait calls; SharedArrayBuffer & Atomics covers the protocol side.

// main.ts — instrument the boundary so the profiler shows clone vs transfer cost
const rows = new Float64Array(1_000_000);          // 8 MB payload

performance.mark('send-start');
worker.postMessage({ rows }, [rows.buffer]);        // transfer: ownership moves
performance.mark('send-end');
performance.measure('post-cost', 'send-start', 'send-end');

console.log(rows.byteLength);                        // → 0: the buffer is now detached
COOP/COEP is a hard prerequisite for shared memory

Firefox exposes SharedArrayBuffer only on a cross-origin isolated document: the response must carry Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp, and every cross-origin subresource must opt in with CORP or CORS. Verify with self.crossOriginIsolated === true inside the worker context, and check the response headers in the Network panel — a missing header is a server config bug, not a JavaScript bug.

Structured clone, transfer and SharedArrayBuffer compared by what crosses, what Firefox shows and how each fails A three-by-three matrix. Columns are structured clone via postMessage of an object, transfer via postMessage with a transfer list, and SharedArrayBuffer with no message at all. Rows are what actually crosses the thread boundary, what the Firefox Debugger and Profiler show you, and the typical failure symptom for each strategy. Three ways a payload crosses the boundary — and how each one shows up in Firefox Structured clone postMessage(obj) Transfer postMessage(o, [o.buffer]) SharedArrayBuffer no message at all What actually crosses A deep copy. Serialized on the sender, deserialized on the receiver. Both sides hold their own. Ownership only. The bytes never move; the sender's ArrayBuffer is detached the instant it lands. Nothing. One allocation, two threads, no wire event to observe at any point. What Firefox shows you Main-thread time inside the postMessage frame, then worker time before your onmessage frame is entered. Scopes shows byteLength: 0 on the sender's view — the check to run when a second send arrives empty. No wire signal at all. First thing to evaluate in the worker console context: self.crossOriginIsolated How it fails Latency grows linearly with payload size. A faster parser changes nothing. Reading a buffer you already gave away. Looks like a race; it is not one. SharedArrayBuffer undefined from missing COOP/COEP, or an Atomics.wait that hangs.
Pick the row that matches your symptom, then read across: each strategy leaves a different fingerprint in the Debugger, so the fingerprint tells you which column you are actually in.

A practical rule for triage: measure the gap between postMessage returning on the sender and the first line of onmessage executing on the receiver. If that gap grows roughly linearly with payload size, the problem is the copy and the fix is a transfer or shared memory — not a faster parser. If the gap is flat and the worker frame itself is long, the problem is your algorithm, and the Firefox Profiler’s call tree will name the function.

Verification & Measurement

After setting up breakpoints and profiling, confirm your setup is working correctly:

  1. Verify thread registration — Open the Debugger → Sources list. The worker’s URL must appear under the Workers heading. If it does not, the worker was created before DevTools opened; reload with DevTools already open.
  2. Verify breakpoints — Send a test message. Execution must pause in the worker pane with the correct line highlighted. If the breakpoint is greyed out (hollow circle), the source file was not matched — check that the sourceURL annotation or source map path is correct.
  3. Verify console context — Type self.constructor.name in the Console with the worker context selected. It must return "DedicatedWorkerGlobalScope", not "Window".
  4. Verify profiler thread — After recording, confirm the worker thread lane is labeled with the correct script URL. Sample count should be non-zero if the worker executed during the recording window.
  5. Measure baseline — Add performance.mark / performance.measure calls and record a profiler trace before optimizing. Record a second trace after changes and compare flame chart shapes and sample counts.

Manual timing is what turns “it feels faster” into a number you can defend. The harness below splits a round-trip into the three costs named earlier — send-side clone, worker compute, reply-side clone — using timestamps taken on both threads. Run it with DevTools closed: an attached debugger adds instrumentation overhead of a few percent, and a paused thread invalidates the measurement entirely.

// bench.ts — split a worker round-trip into transfer cost and compute cost
interface Timed { workerStart: number; workerEnd: number; rows: number }

export async function measureRoundTrip(worker: Worker, csv: string): Promise<void> {
  const sent = performance.now();
  const result = await new Promise<Timed>((resolve) => {
    worker.addEventListener('message', (e: MessageEvent<Timed>) => resolve(e.data), { once: true });
    worker.postMessage({ type: 'PARSE', csv });
  });
  const received = performance.now();

  // performance.now() shares a time origin between window and worker for
  // same-origin dedicated workers, so these numbers are directly comparable.
  console.table({
    'send + deserialize (ms)': +(result.workerStart - sent).toFixed(2),
    'worker compute (ms)':     +(result.workerEnd - result.workerStart).toFixed(2),
    'reply + deserialize (ms)':+(received - result.workerEnd).toFixed(2),
    'total (ms)':              +(received - sent).toFixed(2),
  });
}

For the 5 MB CSV from the opening scenario, a representative split on a mid-range laptop is roughly 25–45 ms send-side (clone of a 5 MB string), 40–80 ms compute for a naive split-based parser, and under 10 ms for the reply if you return a compact typed array rather than a nested array of strings. Those proportions, not the absolute numbers, tell you which fix is worth doing.

One CSV round-trip split into send clone, worker compute and reply clone, before and after transferring the reply Two stacked horizontal bars on a millisecond scale from 0 to 200. The upper bar returns a nested array of strings: 38 ms send clone, 62 ms worker compute, 95 ms reply clone, 195 ms total. The lower bar returns a transferred Float64Array: the same 38 ms send and 62 ms compute, but the reply collapses to 6 ms for a 106 ms total. Send and compute are unchanged; the whole saving is in the reply. One 5 MB CSV round-trip, split by phase Same worker, same parser — only the shape of the reply changes. Reply: nested array of strings total 195 ms send + clone 38 ms worker compute 62 ms reply + clone (nested arrays) 95 ms Reply: transferred Float64Array total 106 ms send + clone 38 ms worker compute 62 ms reply 6 ms — buffer transferred, not copied 0 50 100 150 200 ms The proportions, not the absolute numbers, tell you which fix is worth doing.
Representative split for the 5 MB CSV on a mid-range laptop, measured with the harness above. Send-side clone and worker compute are identical in both runs; returning a compact typed array by transfer removes almost half the total on its own.

Failure Modes & Error Handling

Two categories of failure show up here: the tooling appearing not to work, and the worker itself failing in ways that never surface on the main thread.

Tooling failures

Worker does not appear in Sources list

Cause: The worker was created before DevTools was opened. Fix: Reload the page with DevTools already open. The page must create the worker after DevTools attaches.

Breakpoints are not hit

Cause 1: Source map mismatch. The source file in the Debugger shows the compiled output but breakpoints are set against the original source. Ensure the source map path in the .js file points to an accessible .map file. Cause 2: The worker script URL does not match what Firefox loaded. Check the URL in the Sources list against the path passed to new Worker(...). Cause 3: The message that would trigger the handler was sent before the breakpoint was set.

Console context shows wrong scope

Cause: The context picker reverts to Top after page navigation or worker restart. Fix: Re-select the worker in the context picker after each reload.

Profiler shows no worker thread

Cause: The worker terminated before the profiler stopped recording, or no messages were sent to the worker during the recording window. Fix: Extend the recording duration or add a keep-alive loop in the worker during profiling. Confirm the worker is alive by checking the Sources list.

about:debugging shows no service workers

Cause: The service worker registration failed (check the Console for errors) or the page is not served over HTTPS (service workers require a secure origin, except on localhost).

Runtime failures inside the worker

Firefox’s Debugger has a Pause on exceptions toggle (the ⏸ icon with a hazard sign in the Debugger toolbar) with an optional Pause on caught exceptions sub-setting. Enable it and the debugger stops at the throw site inside the worker thread with the full stack intact — far more useful than reading a flattened message after the fact. Enable the caught-exception variant only while hunting a specific bug: a worker that uses exceptions for control flow (many parsers do) will pause constantly.

Errors that escape a worker do not reach window.onerror. They dispatch an ErrorEvent on the Worker object on the main thread, and unhandled promise rejections fire unhandledrejection on self inside the worker. Instrument both ends, and serialize the error yourself — stack does not reliably survive structured clone in Firefox:

// worker.ts — make every failure observable from the main thread
type WireError = { name: string; message: string; stack?: string };

function toWire(err: unknown): WireError {
  return err instanceof Error
    ? { name: err.name, message: err.message, stack: err.stack }
    : { name: 'NonError', message: String(err) };
}

self.addEventListener('error', (e: ErrorEvent) => {
  // Fires for synchronous throws that escape any handler.
  self.postMessage({ type: 'WORKER_ERROR', error: toWire(e.error) });
});

self.addEventListener('unhandledrejection', (e: PromiseRejectionEvent) => {
  e.preventDefault();                                  // stop the console-only default
  self.postMessage({ type: 'WORKER_ERROR', error: toWire(e.reason) });
});
// main.ts — the receiving half, plus a bounded restart for a dead worker
worker.addEventListener('error', (e: ErrorEvent) => {
  // Fires when the worker script fails to load or throws before handlers attach.
  console.error(`worker failed at ${e.filename}:${e.lineno}`, e.message);
});

let restarts = 0;
function restartWorker(): Worker | null {
  if (restarts >= 3) return null;                      // give up rather than loop forever
  restarts += 1;
  return new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' });
}
A restarted worker loses its debugger state

When a worker is replaced, Firefox registers a new thread: breakpoints set on the old thread's source usually re-bind by URL, but the console context picker falls back to Top and any watch expressions evaluate against a fresh global. Re-select the worker in the picker after each restart, or you will be inspecting the main thread while believing you are inside the worker.

The three routes a worker failure can take out of the worker thread Three rows, each flowing left to right from a failure inside the worker, through how it escapes, to what the main thread sees. A synchronous throw becomes an ErrorEvent on the Worker object and reaches worker.addEventListener error. An unhandled promise rejection fires unhandledrejection on self inside the worker and reaches the main thread only if you forward it. An error you catch yourself is serialized with toWire and posted as a typed WORKER_ERROR message. A footer notes that the Debugger's Pause on exceptions setting halts at the throw site inside the worker with the full stack intact. Three ways a worker failure escapes — and where each one lands Inside the worker How it escapes What the main thread sees Synchronous throw Inside onmessage — a malformed row that JSON.parse rejects. ErrorEvent on the Worker The platform dispatches it on the main thread's Worker object. worker.addEventListener('error') Carries message, filename, lineno. window.onerror never fires for it. Unhandled rejection An await that throws with no .catch() anywhere above it. unhandledrejection on self Fires INSIDE the worker. Nothing is forwarded across on its own. Silence, unless you forward it Call e.preventDefault(), then post the serialized reason yourself. An error you catch try/catch around the parse, so you choose what to report. toWire(err) + postMessage You own the wire format: name, message, stack as plain strings. A typed WORKER_ERROR message The stack survives because you serialized it — Error.stack alone does not reliably clone in Firefox. Debugger ▸ Pause on exceptions halts at the throw site inside the worker, with the full worker stack intact.
Only the first route reaches the main thread by itself. Rows two and three are yours to wire up — which is why a worker that "fails silently" is almost always an unforwarded rejection.

The wire format above is deliberately minimal; the production version — with cause chains, fingerprinting and source-mapped stacks — is covered in Structured Error Serialization Across Threads, and the crash-recovery policy around restarts in Error Handling & Crash Recovery. If the worker survives but its memory keeps growing between messages, that is a different investigation: see Identifying Memory Leaks in Workers.

Browser Compatibility & Tooling Comparison

Feature Firefox Chrome Safari Edge
Worker thread in Debugger Sources list Firefox 99+ Chrome 38+ Safari 16+ Edge 79+
Breakpoints in worker scripts Firefox 46+ Chrome 38+ Safari 16+ Edge 79+
Console context picker for workers Firefox 56+ Chrome 72+ Not available Edge 79+
about:debugging for service workers All modern chrome://serviceworker-internals/ Not equivalent Same as Chrome
Profiler per-thread flame chart Firefox 55+ Chrome DevTools Performance Not available Edge DevTools Performance
performance.mark in workers Firefox 41+ Chrome 43+ Safari 11+ Edge 16+
Source maps in worker scripts Firefox 48+ Chrome 38+ Safari 16+ Edge 79+
Blob worker //# sourceURL annotation Firefox 48+ Chrome 38+ Safari 16+ Edge 79+

For a detailed feature-by-feature comparison with actionable guidance on when to use each browser’s tools, see Comparing Chrome and Firefox Worker Tooling.

The key practical difference is the profiler: Firefox Profiler (accessible at profiler.firefox.com) is a standalone shareable tool with superior per-thread filtering. Chrome’s Performance panel is integrated into DevTools and ties more tightly to the rendering pipeline. If your bottleneck is in worker CPU time, profile in Firefox first.

For teams already familiar with Chrome DevTools, the Chrome DevTools Worker Debugging reference covers the equivalent workflow. Checking postMessage Bottleneck Analysis is also useful when worker pauses look healthy but end-to-end latency is still high — often the bottleneck is in serialization cost, not worker computation.

Frequently Asked Questions

How do I make a worker appear in the Firefox Debugger's thread list?
The worker must be running when you open DevTools. Open DevTools first, then trigger the code path that calls new Worker(...). If the worker has already terminated, it will not appear. For blob: workers, Firefox shows the URL as blob:... — set your breakpoint in the Sources list before the worker starts to catch initialization.
How do I evaluate an expression in a worker's context in Firefox?
In the DevTools Console, click the context-picker dropdown (it shows ‘Top’ by default) and select the worker thread by name or URL. Any expression you type then runs in that worker’s global scope — self, worker-local variables, and all. The worker does not need to be paused for this to work.
Can Firefox profile individual worker threads separately?
Yes. In the Firefox Profiler (available via the Performance panel or profiler.firefox.com), each thread appears as a separate lane in the timeline. Worker threads are labeled by their script URL. You can filter the flame chart to a single thread to isolate CPU time spent in worker code without main-thread noise.
What is the difference between about:debugging and the Debugger panel for workers?
about:debugging lists all active service workers across all origins and lets you inspect, force-update, or unregister them — it is a global registry, not a per-page tool. The DevTools Debugger panel shows dedicated workers for the current page only, lets you set breakpoints in worker scripts, and is where you pause and step through worker execution.

See also