Inspecting Worker Scopes in Firefox DevTools
When a dedicated Web Worker is paused at a breakpoint, the Firefox Debugger’s Scopes pane exposes its entire lexical environment — block-scoped bindings, parameters, closure captures, module-level state, and the DedicatedWorkerGlobalScope object itself — and the console’s evaluation-context picker lets you run arbitrary expressions inside that same realm. This page is the scope-reading chapter of the Firefox Worker Debugging walkthrough, which sits inside the Debugging, Profiling & Production Optimization reference; if you want the equivalent panes in Chrome and where the two disagree on labelling, see Comparing Chrome and Firefox Worker Tooling.
The narrow problem this page solves: you have paused inside a worker, the value is wrong, and you need to know which scope layer owns the binding you are looking at — because a stale module-level counter, a captured closure variable, and a parameter that was structured-cloned across the thread boundary all look identical in a console.log.
A Minimal Repro That Populates Every Scope Layer
The pair below is the smallest complete example that fills in every section of the Scopes pane. It deliberately stacks the layers: a parameter (Local), a block-scoped const (Block), a captured variable (Closure), a module-level counter (Module), and the worker global itself.
// worker.ts — a module worker; served as-is or bundled with a source map
interface Job { value: number; factor: number }
interface Done { result: number; processedCount: number }
let processedCount = 0; // module-level state -> appears under "Module"
// makeScaler captures `label`, so its returned function has a real Closure scope
function makeScaler(label: string) {
return function applyMultiplier(value: number, factor: number): number {
const scaled = value * factor; // block-scoped -> appears under "Block"
console.debug(label, scaled);
return scaled;
};
}
const scale = makeScaler('scaler-a');
self.onmessage = ({ data }: MessageEvent<Job>) => {
const result = scale(data.value, data.factor); // <- set the breakpoint on this line
processedCount++;
const payload: Done = { result, processedCount };
self.postMessage(payload);
};
// main.ts — the driver page
const worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' });
worker.onmessage = ({ data }: MessageEvent<{ result: number; processedCount: number }>) => {
console.log(`Result: ${data.result}, total processed: ${data.processedCount}`);
};
// Call this from the console (or a button) once DevTools is open and the breakpoint is set
export function triggerWorker(): void {
worker.postMessage({ value: 42, factor: 3 });
}
(globalThis as unknown as { triggerWorker: () => void }).triggerWorker = triggerWorker;
Two details matter for the scopes you are about to read. { type: 'module' } is what produces a Module section — a classic worker loaded with importScripts has no module record, so its top-level let and const bindings land in the Worker global instead. And makeScaler exists only to create a genuine closure: without it, the Closure section is absent, which is why so many first attempts at this exercise produce a pane with a hole in it.
makeScaler and the Closure row disappears; drop { type: 'module' } and the Module row's contents move down into Worker.Walkthrough: Reading the Scopes Pane
1. Open DevTools first, then set the breakpoint
Press F12 before the page constructs the worker and switch to the Debugger panel. Firefox registers worker threads as they start, so a worker created before the tools opened may be listed without a debuggable script. In the source list, expand the Workers heading, click worker.ts, and click the gutter next to const result = scale(data.value, data.factor); — a blue marker confirms the breakpoint.
2. Trigger the worker and read the frame
Call triggerWorker() in the Console (with the context still on Top, since that function lives on the page). Execution pauses inside the worker, the Debugger’s Call Stack shows the worker frame, the thread selector switches to the worker automatically, and the Scopes pane populates on the right.
3. Work down the scope layers
Firefox lists scopes innermost-first. At this breakpoint you will see:
| Section | What it holds here | Value at the pause |
|---|---|---|
| Block | Block-scoped let/const in the current block, including ones still in the temporal dead zone |
result: <uninitialized> |
| Local | Parameters and var declarations of the current function |
data: {value: 42, factor: 3} |
| Closure | Variables captured from an enclosing function | (absent in this frame — onmessage is top-level) |
| Module | Top-level bindings of the module worker | processedCount: 0, scale: function, makeScaler: function |
| Worker | The global object, DedicatedWorkerGlobalScope |
self, postMessage, caches, indexedDB, fetch, … |
result: <uninitialized> is not a bug: the right-hand side has not been evaluated yet, so the binding exists but is in its temporal dead zone. Reading it from the console at this instant throws a ReferenceError, which is exactly what the label is telling you. The Worker section is where Firefox differs from Chrome, which labels the same object Global — a small thing that matters when you are following a Chrome-written bug report against a Firefox session.
If a variable you expect under Module shows up under Worker instead, the script is running as a classic worker, not a module worker — the bundler emitted a non-module chunk, or the { type: 'module' } option was dropped somewhere in the build. That single observation explains a whole class of "my import silently did nothing" reports.
4. Step into the closure
Press F11 to step into applyMultiplier. A new frame is pushed and the pane redraws for it:
- Block:
scaled: <uninitialized> - Local:
value: 42,factor: 3 - Closure (makeScaler):
label: "scaler-a"— the captured variable, now visible with the name of the function that captured it - Module / Worker: unchanged, because they belong to the realm rather than the frame
Press F10 to execute const scaled = value * factor. The Block entry flips to scaled: 126. Watching a binding move from <uninitialized> to a value is the cheapest way to confirm you are on the line you think you are on — far more reliable than counting line numbers in a bundled file.
Evaluating Expressions in the Worker Realm
The Scopes pane reads state; the console evaluation-context picker lets you write and call into it. Switch to the Console panel and click the picker in the toolbar — it reads Top by default — then choose the worker thread, listed by its script URL. Everything you type now runs in the worker’s global scope, and while the worker is paused, in the scope of the selected stack frame.
// Console, with the evaluation context set to worker.ts
self.constructor.name // -> "DedicatedWorkerGlobalScope" (confirm the realm first)
typeof document // -> "undefined" (no DOM in a worker)
processedCount // -> 0 module-level state, read before the increment
data.value * data.factor // -> 126 frame-local: only resolves while paused
scale(10, 5) // -> 50 calls the worker's own function, no postMessage
self.crossOriginIsolated // -> false unless COOP/COEP are set on the document
That fourth line is the reason to bother with the picker at all: you are calling a function inside the worker’s heap, with its real module state, and getting the return value back synchronously in the console — no postMessage round-trip, no temporary message type, no rebuild. For pure transforms it collapses a five-minute edit-reload-message cycle into one keystroke.
Always start with self.constructor.name. The picker silently resets to Top when the worker terminates or the page reloads, and page-scope evaluation of a worker-local name returns undefined rather than an error — so a stale context looks exactly like a variable that was never assigned.
Watch expressions cover the values you check on every pause. In the Debugger’s right sidebar, open Watch Expressions, click +, and add processedCount; add a second, data.value * data.factor. Both re-evaluate on every pause, so you no longer expand the Module scope by hand each time round the loop. Expressions that reference frame-local names (data, scaled) show an error while the worker is running and resolve as soon as it pauses in a frame where those names are in scope — that is expected, not a broken watch.
ReferenceError; a missing realm hands back undefined and lets you keep debugging the wrong heap.Gotchas & Edge Cases
1. Bundled workers need a reachable source map, or the pane goes blank. When a worker is bundled by Vite or webpack, Firefox can only show original bindings if the source map loads. The classic failure is a map that references ../../src/worker.ts while the dev server refuses to serve paths outside its output directory. Diagnose it from the Sources list: an entry ending in .js where you expected .ts means the map never arrived. Set devtool: 'source-map' (webpack) or build.sourcemap: true (Vite), and confirm the .map file is served — the full build-side treatment is in Bundling Module Workers with Vite and webpack.
2. A short-lived worker never gets a scope to inspect. A worker that starts and terminates before you can hit Trigger will appear in the Sources list only fleetingly, and never pauses. Two fixes: use the Debugger’s Event Listener Breakpoints → Worker → message entry so Firefox pauses on the first delivered message before any application state exists, or keep the worker alive deliberately during the session with a long setTimeout inside it. The event-listener approach is better, because it also catches workers that throw during their first message — the failure mode covered in Fixing Uncaught Exceptions in Dedicated Workers.
3. Blob workers show a blob: URL where a file name should be. A worker built from URL.createObjectURL(new Blob([...])) is identified by an opaque blob:https://… URL in both the Sources list and the thread label above the Scopes pane, and that URL changes on every reload — so breakpoints are orphaned too. Add a //# sourceURL= annotation as the first line of the blob source and both surfaces get a stable, readable name:
// Annotate an inline worker so the Scopes pane and Sources list name it usefully
const src = `
//# sourceURL=data-processor.js
let state = 'ready';
self.onmessage = ({ data }) => {
state = 'processing';
self.postMessage(data.value * 2);
state = 'ready';
};
`;
const url = URL.createObjectURL(new Blob([src], { type: 'application/javascript' }));
const worker = new Worker(url);
URL.revokeObjectURL(url); // safe once the Worker constructor has fetched it
The trade-offs of this worker style are covered in Inline Workers vs Dedicated Workers.
4. Top-level await leaves half a Module scope. If the worker module awaits at top level — const wasm = await WebAssembly.instantiateStreaming(fetch('/f.wasm')) is the common case — and a breakpoint fires while that await is still pending, the Module section shows the bindings declared before the await with values, and everything after it as <uninitialized>. Nothing is broken; module evaluation simply has not reached those declarations. Resume past the await, or move the breakpoint after it, before concluding that an import failed.
5. A SharedArrayBuffer binding renders only when the document is cross-origin isolated. Firefox has shown SharedArrayBuffer with its byteLength in the Scopes pane since Firefox 79, but the binding cannot exist at all unless isolation is enabled — so a missing buffer is a headers problem, not a DevTools problem.
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. Evaluate self.crossOriginIsolated in both realms — the page and the worker context — before blaming the Scopes pane; a dev server that sets the headers and a staging proxy that strips them is the usual reason a buffer is visible locally and absent in staging. See SharedArrayBuffer & Atomics for the memory model and Debugging SharedArrayBuffer Cross-Origin Errors for the header audit.
Performance Note: What Inspection Costs
Expanding a scope node is free. The Scopes pane walks SpiderMonkey’s existing environment records for the paused frame — no script runs, no allocation happens in the worker’s heap, and nothing you expand can change the values you are looking at.
Everything else on this page is not free, in two distinct ways. Attaching the debugger costs roughly 5–15% of worker CPU time, because SpiderMonkey disables some optimisations for a thread with a debugger attached: a 55 ms parse measured with DevTools closed reads as 58–63 ms with the panel open. Evaluating a console expression actually executes code in the worker’s heap — so scale(10, 5) is harmless, but calling a function that allocates a 50 MB typed array, forces a garbage collection, or mutates module state will contaminate every measurement taken afterwards in that session, and can also change program behaviour on resume.
Inspect and evaluate freely while you are diagnosing correctness; take every timing number in a separate run with DevTools closed. If you need in-flight numbers without pausing, instrument the worker with performance.mark / performance.measure and read performance.getEntriesByType('measure') from the worker console context — cumulative timings, no breakpoint, no debugger hooks.
Applied to the repro: mark either side of scale(...), run the workload with the tools closed, then open the console, switch the context to the worker, and read the entries out at the end. That is the same discipline used for serialization measurements in Measuring Structured Clone Cost with performance.now() — pause to understand, measure without pausing.