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.

Which declaration in worker.ts lands in which Scopes pane section The worker source on the left, the Firefox Scopes pane on the right, with five leader lines. The const scaled inside applyMultiplier maps to Block. The destructured data parameter of self.onmessage maps to Local. The label parameter captured by makeScaler maps to Closure. The module-level let processedCount maps to Module. And self maps to the Worker global. A note adds that a classic worker loaded with importScripts has no module record, so those same top-level bindings appear under Worker instead. Where each declaration surfaces in the Scopes pane worker.ts — module worker let processedCount = 0; function makeScaler(label) { return function applyMultiplier(value, factor) { const scaled = value * factor; }; } self.onmessage = ({ data }) => { const result = scale(data.value, data.factor); self.postMessage(payload); Scopes — innermost first Block scaled · result — every const in the block Local data, value, factor — this frame's params Closure (makeScaler) label: "scaler-a" — the captured variable Module processedCount, scale, makeScaler Worker self, postMessage, fetch, caches, … A classic worker (importScripts) has no module record — those same top-level bindings land under Worker instead.
Every section of the pane is filled by exactly one construct in the repro. Delete 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.

Module vs Worker is the diagnosis, not a detail

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.

The Scopes pane before and after F11 steps into applyMultiplier Two copies of the Scopes pane side by side. Before F11, in the onmessage frame: Block holds result uninitialized, Local holds the cloned data object, Closure is absent, Module holds processedCount zero, and Worker is the DedicatedWorkerGlobalScope. After F11, in the applyMultiplier frame: Block holds scaled uninitialized which becomes 126 after one F10, Local holds value 42 and factor 3, and a Closure section named makeScaler holds label scaler-a. A band down the middle marks the top three sections as per frame, rebuilt on every stack push, and the bottom two as per realm, the same objects in every frame. One step, two frames: what redraws and what does not F11 · step into Before F11 — frame: onmessage Block result: <uninitialized> right-hand side not evaluated yet — TDZ Local data: {value: 42, factor: 3} the structured-cloned message payload Closure — section absent — onmessage is top-level and captures nothing Module processedCount: 0 scale: function · makeScaler: function Worker DedicatedWorkerGlobalScope self · postMessage · caches · fetch · … per frame rebuilt from scratch on every stack push per realm the same objects in every frame After F11 — frame: applyMultiplier Block scaled: <uninitialized> flips to 126 after a single F10 Local value: 42 · factor: 3 the arguments of this call Closure (makeScaler) label: "scaler-a" new section — named after the capturer Module processedCount: 0 unchanged — the same record, not a copy Worker DedicatedWorkerGlobalScope unchanged — the same global object Only the top three sections change when you step — Module and Worker belong to the realm, not the frame.
Stepping does not refresh a view of one environment; it pushes a new one. That is why Closure can appear out of nowhere on F11, and why a value you read under Module is the same object no matter which frame you are looking from.

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.

The console evaluation-context picker, and the same expressions in both realms At the top, the Firefox console toolbar with the context picker expanded, offering Top — the page realm with window and document — and worker.ts, the worker realm, which is selected. Beside it a warning that the picker silently resets to Top. Underneath, a table of four expressions evaluated in each context: self.constructor.name returns Window versus DedicatedWorkerGlobalScope; typeof document returns object versus undefined; processedCount returns undefined versus zero; and scale of ten and five throws a ReferenceError versus returning fifty. The picker decides which heap your keystrokes land in Console worker.ts evaluation-context picker Top the page realm — window, document worker.ts the worker realm — self, no DOM Always type self.constructor.name first The picker silently resets to Top when the worker terminates or the page reloads. On Top, a worker-local name returns undefined rather than throwing — a stale context looks exactly like a name that was never set. Expression context: Top context: worker.ts self.constructor.name "Window" the page's global, not the worker's "DedicatedWorkerGlobalScope" realm confirmed — trust the rest typeof document "object" the DOM is right there "undefined" no DOM inside a worker processedCount undefined no error — the name does not exist here 0 live module state, before the increment scale(10, 5) ReferenceError the function lives in the other realm 50 runs in the worker heap, no postMessage hop Every row here resolves while the worker merely runs; frame-local names such as data or scaled resolve only while it is paused.
The trap is row three, not row four. A missing function announces itself with a 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.

COOP / COEP required before a SharedArrayBuffer appears in any scope

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.

Four failure modes that make the Scopes pane look broken Four panels. One: a bundled worker whose Sources entry reads worker-a1b2c3.js instead of worker.ts, so the Scopes pane shows no bindings — fixed by emitting and serving the source map. Two: a worker that lives about forty milliseconds and has terminated long before you click Trigger, so it never pauses — fixed with the Worker message event-listener breakpoint. Three: a blob worker whose URL changes on every reload, orphaning the breakpoint — fixed with a sourceURL annotation on the first line. Four: a Module scope split by a pending top-level await, where bindings declared before the await have values and everything after reads uninitialized. Four ways an empty or half-filled pane is not a DevTools bug 1 Bundled worker, no source map Sources ▸ Workers worker-a1b2c3.js you expected worker.ts Scopes (no bindings) Fix: emit the .map and make sure the dev server actually serves it. 2 Gone before you can pause it worker alive ≈ 40 ms you click Trigger thread listed in Sources, but it never pauses Fix: Event Listener Breakpoints → Worker → message stops it on delivery. 3 blob: URLs rotate on every reload reload 1 blob:https://app/8f2c-41a9 reload 2 blob:https://app/e91a-77b3 breakpoint orphaned Fix: //# sourceURL=data-processor.js as the blob's first line — a stable name in the Sources list and above the Scopes pane. 4 Top-level await splits Module Module cfg: {…} declared before the await await instantiateStreaming(…) ← pending wasm: <uninitialized> api: <uninitialized> Nothing failed: evaluation has simply not reached those declarations yet. All four look like the tooling failing. All four are the build, the lifetime or the module graph — check them before filing a bug.
The shared shape of all four: the debugger is reporting the truth about a program that is not the one you think you are running. Two are build-side, one is lifetime, one is module evaluation order — and none of them are fixed inside DevTools.

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.

Rule of thumb

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.

What inspection costs: the same 55 ms worker task measured three ways Three horizontal bars against a millisecond axis. With DevTools closed the task takes 55 milliseconds, the number worth reporting. With the debugger attached it reads 58 to 63 milliseconds, a 5 to 15 percent overhead shown as a bracket past the 55 millisecond baseline. With a console evaluation that allocates or forces a garbage collection, the bar runs off the axis and the result is not comparable at all. What inspection costs: one 55 ms worker task, measured three ways 55 ms baseline DevTools closed the number you report 55 ms Debugger attached SpiderMonkey drops optimisations 58 – 63 ms +5–15% …plus a console evaluation that allocates or forces a GC not comparable 0 15 30 45 60 75 ms Expanding a scope node is free — it only reads environment records that already exist. Attaching and evaluating are not. A single large allocation or forced GC in the worker heap invalidates every measurement taken after it in that session.
Read the third bar as a category, not a length: once an evaluation has allocated inside the worker's heap, the session no longer produces numbers worth comparing. Pause to understand; measure in a separate run with the tools closed.

Frequently Asked Questions

Why is the Scopes pane empty when I pause inside a worker?
An empty pane almost always means Firefox cannot resolve a source location for the frame it stopped on. Three causes cover nearly every case. (1) The breakpoint sits on a bundled .js file whose source map is missing or unreachable, so the debugger has no binding metadata to map back — check the Sources list: if the worker entry ends in .js rather than your original file name, fix the map first. (2) Execution is paused on the main thread while the worker thread is selected, or vice versa; the pane only renders scopes for the frame highlighted in the Call Stack. (3) The worker started before DevTools opened, so the thread is listed but its script was never registered with the debugger — reload with the tools already open. If the pane shows sections but every value reads <uninitialized>, you are inside the temporal dead zone: step one line further and the bindings populate.
Can I modify a variable's value from the Scopes pane while paused?
Yes, for primitives. Double-click a value in the Scopes pane to enter edit mode, type a new number, string or boolean, and press Enter — the binding is rewritten in the live worker realm and takes effect the moment you resume. This is the fastest way to test an edge case without editing and reloading the worker script: flip a feature flag, force a loop counter to its last iteration, or push a chunk size past a threshold. Two limits: you cannot replace an object wholesale (edit its properties individually, or assign through the console context instead), and you cannot write to a const binding — Firefox accepts the edit visually but the assignment throws on resume.

See also