Chrome DevTools Worker Debugging
A worker is a separate V8 isolate: its own heap, its own event loop, its own global object, and — crucially for tooling — its own debug target. Every habit that works on the main thread (set a breakpoint, take a snapshot, read the flame chart) still works, but only after you point DevTools at the right isolate. This guide belongs to the Debugging, Profiling & Production Optimization reference and walks the complete Chrome workflow: attaching to a worker, breaking on its first message, recording a trace that includes worker tracks, extracting postMessage cost from that trace, diffing per-worker heap snapshots, and validating the cross-origin isolation that SharedArrayBuffer demands.
The Symptom You Are Debugging
The typical arrival story is this. A 12 MB CSV parse used to block the UI for roughly 1,400 ms. You moved the parse into a worker, and the freeze shrank — but it did not disappear. The main thread still drops frames for 150–200 ms around each parse, the worker returns correct rows, and there is no error anywhere. Nothing in the console tells you where the remaining time went.
Three suspects account for almost every case:
- Boundary cost. The payload is copied twice — once serialized on the sender, once deserialized on the receiver — and both halves run on a thread that cannot paint while they run. A 12 MB string or a 300,000-node object graph is not free just because the parse moved.
- Worker CPU. The algorithm itself is slow, and moving it off the main thread only hid it. It still delays the response, so the UI shows a spinner for longer than the product tolerates.
- Retention. The worker keeps every batch it has ever seen in a cache that nobody clears, so the tenth parse runs against a heap several hundred megabytes larger than the first, with GC pauses to match.
Chrome can tell all three apart, but only with the right panel pointed at the right thread. Throughput-focused measurement of the first suspect is covered in depth by postMessage Bottleneck Analysis; the third has its own workflow in Identifying Memory Leaks in Workers. This page is the tooling layer that lets you attribute the time in the first place.
Prerequisites
- Chrome 90 or later. Earlier builds lack the per-thread selector in the Memory panel; Chrome 126+ additionally draws
postMessageinitiator arrows in the Performance panel. - Workers loaded as real script URLs, ideally as modules:
new Worker(new URL('./parse.worker.ts', import.meta.url), { type: 'module' }). Bundler-generated blob workers still debug, but they need a//# sourceURLannotation to be readable (see Step 2), and module workers get the cleanest source-map behaviour — the bundler setup is covered in Bundling Module Workers with Vite and webpack. - Source maps deployed with the bundle. Without a reachable
.map, breakpoints land in minified output and snapshot constructor names are single letters. - A reproducible workload. A button that always sends the same fixture beats a live data feed; you will run it four or five times per investigation.
- For any
SharedArrayBufferstep: the document must be served withCross-Origin-Opener-Policy: same-originandCross-Origin-Embedder-Policy: require-corp. Local file:// URLs cannot satisfy this; serve overhttp://localhostor HTTPS.
Step 1 — Attach to the Worker Isolate
Open DevTools (F12), go to Sources, and look for the Threads pane in the left sidebar. It lists the main frame plus every live dedicated worker, labelled by script URL. Clicking an entry makes that isolate the active debug target: the file tree, the call stack, the Scope pane, and the Console’s execution-context selector all follow it.
Two consequences matter immediately. First, breakpoints are per-isolate — a breakpoint set while the main frame is selected will never fire in worker code that happens to contain the same bundled module. Second, the Console’s context dropdown (the one that says top) now offers the worker; select it and every expression you type, including Live Expressions, evaluates in DedicatedWorkerGlobalScope.
// main.ts — a typed handle on the worker, with the message contract in one place
interface ParseRequest { type: 'PARSE'; csv: string; batchId: number }
interface ParseResult { type: 'RESULT'; rows: number; batchId: number }
interface WorkerReady { type: 'READY' }
type FromWorker = ParseResult | WorkerReady;
const worker = new Worker(new URL('./parse.worker.ts', import.meta.url), {
type: 'module',
name: 'csv-parser', // ← shows up as the thread label in DevTools
});
worker.onmessage = ({ data }: MessageEvent<FromWorker>) => {
if (data.type === 'READY') return;
console.log(`batch ${data.batchId}: ${data.rows} rows`);
};
// An error inside the worker surfaces here, not on window.onerror
worker.onerror = (event: ErrorEvent) => {
console.error('worker fault', event.message, event.filename, event.lineno);
};
export function parse(csv: string, batchId: number): void {
worker.postMessage({ type: 'PARSE', csv, batchId } satisfies ParseRequest);
}
// parse.worker.js — worker-side internals; breakpoint target is the guard on line 4
self.onmessage = ({ data }) => {
if (data.type === 'PARSE') {
// Pause here and inspect `data.csv.length` before any parsing happens.
const rows = parseCsv(data.csv);
self.postMessage({ type: 'RESULT', rows: rows.length, batchId: data.batchId });
}
};
function parseCsv(raw) {
return raw.split('\n').map((line) => line.split(','));
}
self.postMessage({ type: 'READY' });
The name option on the constructor is the cheapest debugging win on this page: without it, three workers spawned from the same bundle chunk are three identical URLs in the Threads pane. With it, they are csv-parser, image-resizer, and index-builder.
Setting a breakpoint inside a worker pauses only that worker's event loop. The main thread and all other workers keep running, so messages pile up in the paused worker's queue and all of them process in order the moment you resume. This is a trade-off, not a defect: it means you can inspect worker state without freezing the UI, but any measurement you take while paused (queue depth, elapsed time, animation smoothness) is meaningless.
Step 2 — Break on Worker Startup and on the First Message
Most worker bugs that are actually hard live in initialization: a config object that arrives half-populated, a importScripts call that 404s, a module that throws before onmessage is ever assigned. By the time you have clicked into the Threads pane, that moment is gone.
Three ways to catch it, in order of preference:
debugger;on the first line of the worker script. Blunt, reliable, works even for blob workers, and survives bundling. Remove it before shipping — or guard it behind aimport.meta.env.DEVcheck.- Event Listener Breakpoints → Script → Script First Statement. Enable it in the Sources sidebar, then reload. Chrome pauses on the first statement of every script it evaluates, including each worker’s entry module. Noisy on a large app; pair it with the ignore list below.
- Event Listener Breakpoints → Worker → message. Pauses whenever a message is dispatched, which is the right tool when the payload — not the startup — is what you distrust.
importScripts, a module that throws before onmessage exists — all happen in the shaded startup window, which is finished before a hand-set breakpoint could ever land.For workers created from a blob (new Worker(URL.createObjectURL(blob))), add a sourceURL annotation so the isolate has a readable name in the Threads pane and in stack traces:
// main.js — give a blob worker a stable identity in DevTools
const source = `
//# sourceURL=inline-hash-worker.js
self.onmessage = ({ data }) => {
self.postMessage(fnv1a(data.text));
};
function fnv1a(str) {
let h = 0x811c9dc5;
for (let i = 0; i < str.length; i++) {
h ^= str.charCodeAt(i);
h = Math.imul(h, 0x01000193) >>> 0;
}
return h;
}
`;
const blob = new Blob([source], { type: 'text/javascript' });
const worker = new Worker(URL.createObjectURL(blob), { name: 'hash' });
Once paused, add the bundler runtime to Settings → Ignore List (or right-click a frame in the call stack and choose Add script to ignore list). Stepping through worker code that was compiled by Vite or webpack otherwise means stepping through module-registry shims on every Step Into.
If the worker URL 404s or violates CSP, new Worker() does not throw. The failure is delivered asynchronously as an error event on the Worker object, so a page with no worker.onerror handler shows an empty Threads pane and no console exception. Always attach the handler first, then debug — this is the single most common reason a worker "does not appear in DevTools".
Step 3 — Record a Performance Trace That Includes Worker Tracks
The Performance panel captures every thread in the renderer process automatically; each live worker gets its own track, stacked below the main thread’s. There is no checkbox to enable — what trips people up is that a worker which starts after recording begins appears mid-timeline, and a worker that terminates during the recording leaves an empty lane behind.
Recording procedure:
- Open Performance, set CPU: 4× slowdown so short tasks become legible, and leave Screenshots on so you can correlate jank with what the user saw.
- Click record, trigger exactly one workload run, stop. Traces longer than about 10 seconds get hard to read; one run is enough.
- Find the worker track — it is labelled with the script URL, or with the
nameyou passed to the constructor. - Expand the track and read it top-down:
Task → Run Microtasks / Event: message → Function Call → your code.
Instrument the worker before you record. User timings created inside a worker are attached to that worker’s track, which turns an opaque 90 ms block into three labelled phases:
// parse.worker.ts — user timings become labelled spans on the worker's track
interface ParseRequest { type: 'PARSE'; csv: string; batchId: number }
self.onmessage = ({ data }: MessageEvent<ParseRequest>) => {
if (data.type !== 'PARSE') return;
performance.mark('deserialize-done'); // first line: clone already happened
const rows = data.csv.split('\n');
performance.mark('split-done');
const parsed = rows.map((line) => line.split(','));
performance.mark('parse-done');
performance.measure('split', 'deserialize-done', 'split-done');
performance.measure('fields', 'split-done', 'parse-done');
self.postMessage({ type: 'RESULT', rows: parsed.length, batchId: data.batchId });
};
Because performance.mark('deserialize-done') runs on the first line of the handler, the gap between the start of the Event: message task and that mark is deserialization cost — the number you cannot otherwise see.
4× CPU throttling makes structure visible, not absolute numbers meaningful. Worker CPU scales roughly linearly under throttling, but the fixed costs of a hop — task scheduling, event dispatch — do not scale the same way, so a trace at 4× overstates compute relative to messaging. Confirm any go/no-go decision on an unthrottled trace, and on real hardware if the product ships to low-end devices.
Step 4 — Read Serialization Cost Out of the Trace
This is where the missing 180 ms usually turns up. Structured clone is not a separate top-level trace entry: serialization is billed to the task that calls postMessage, and deserialization to the task that delivers the message event. In Chrome 126 and later the panel connects the two with an initiator arrow — click the postMessage entry on the sender and the receiving handler is highlighted on the worker track.
What to look for, in order:
- A main-thread task that is long but contains almost no function calls of yours. That shape — a wide
Function Callblock with a thin sliver of your code at the bottom — is serialization. - A gap between the sender’s task and the receiver’s task. That is queueing, and it means the worker was busy; the fix is a queue or a pool, not a faster payload.
- A worker task whose first user timing mark starts well after the task does. That leading gap is deserialization.
Deep object graphs are dramatically worse than their byte count suggests, because structured clone walks every node, tracks a reference map for cycles, and allocates a fresh object on the far side. Binary payloads copy at memory-bandwidth speed; a 300,000-node array of small objects does not.
| Payload (12 MB logical size) | Serialize + deserialize, one hop | Notes |
|---|---|---|
ArrayBuffer, cloned |
~10–25 ms | Bulk memcpy on both sides |
ArrayBuffer, transferred |
< 0.1 ms | Ownership moves; nothing is copied |
| 12 MB JSON string | ~15–40 ms | Copy plus encoding work |
| ~300k-object array | ~120–250 ms | Per-node graph walk dominates |
Numbers are order-of-magnitude figures from a mid-range 2021 laptop, unthrottled; measure your own payloads with the harness in the verification section, or with the performance.now() pattern in Measuring Structured Clone Cost with performance.now(). The algorithm’s exact semantics — what is cloneable, how cycles and Map/Set/Date are handled, why functions and DOM nodes throw — are laid out in the Step-by-Step Guide to the Structured Clone Algorithm.
The fix for the binary case is to stop copying and start transferring:
// main.ts — hand ownership to the worker instead of copying
const samples = new Float32Array(3_000_000); // 12 MB
samples.set(collectSamples());
const worker = new Worker(new URL('./dsp.worker.ts', import.meta.url), {
type: 'module',
name: 'dsp',
});
// The second argument is the transfer list: buffer ownership moves to the worker.
worker.postMessage({ type: 'ANALYSE', buffer: samples.buffer }, [samples.buffer]);
console.log(samples.buffer.byteLength); // 0 — the buffer is now detached here
worker.onmessage = ({ data }: MessageEvent<{ buffer: ArrayBuffer }>) => {
// The worker transfers ownership back, so this side can read it again.
const result = new Float32Array(data.buffer);
render(result);
};
After a transfer the sending side's buffer is detached: byteLength reads 0 and any typed-array write throws TypeError: Cannot perform %TypedArray%.prototype.set on a detached ArrayBuffer. Single ownership is what makes the pattern race-free, but it means a shared "latest frame" buffer must be transferred back before the next send, or reallocated. The trade-off is spelled out in Transferable Objects & Zero-Copy.
Step 5 — Diff Heap Snapshots on the Worker Isolate
The Memory panel snapshots one JavaScript VM instance at a time. Before clicking Take snapshot, select the worker in the VM-instance selector at the top of the panel (labelled Select JavaScript VM instance in older builds, presented as a Threads dropdown in newer ones). A snapshot taken with the main frame selected contains no worker objects at all — a common way to conclude, wrongly, that a worker is not leaking.
The three-snapshot procedure:
- Warm up: run the workload once, then click Collect garbage (the trash icon). This clears one-shot allocations so your baseline is not noise.
- Take snapshot 1 (baseline).
- Run the workload five to ten times.
- Click Collect garbage again, then take snapshot 2.
- Switch the view selector from Summary to Comparison, with snapshot 1 as the base. Sort by Delta — constructors with a positive delta after forced GC are retained, not merely allocated.
Then select a retained object and read the Retainers pane at the bottom: it shows the chain from a GC root down to the object. In workers, the chain almost always terminates in the worker global — a module-scope Map, an array of results kept “for debugging”, or a closure captured by an event listener that is never removed.
// parse.worker.js — a leak and its fix, side by side
const cache = new Map(); // module scope: lives as long as the worker does
self.onmessage = ({ data }) => {
// LEAK: every batch is retained forever, keyed by an ever-growing id.
cache.set(data.batchId, parseCsv(data.csv));
// FIX 1 — bound the cache explicitly.
if (cache.size > 32) {
cache.delete(cache.keys().next().value); // Maps iterate in insertion order
}
// FIX 2 — if the values are only needed for the current request, do not
// hold them at module scope at all; keep them local to this handler.
self.postMessage({ type: 'RESULT', size: cache.size });
};
function parseCsv(raw) {
return raw.split('\n').map((line) => line.split(','));
}
A worker heap that grows monotonically across identical workloads is a leak even when the numbers look small: the isolate is never torn down between messages, so a 400 KB per-message retention becomes 40 MB after a hundred messages and starts costing you GC pauses on the worker track. The snapshot-diffing technique, including retainer chains that cross a MessageChannel, is expanded in Heap Snapshot Diffing for Worker Leaks.
To force collection from code rather than the panel button, launch Chrome with --js-flags="--expose-gc" and call globalThis.gc() — useful for automated memory regression tests, unavailable in normal browsing.
Taking a heap snapshot forces a full GC and freezes that isolate for the duration — tens to hundreds of milliseconds on a large heap. Snapshot the worker, not the main frame, while chasing a worker leak: it keeps the freeze off the thread that paints, and it keeps the document's own objects out of your comparison view.
Step 6 — Validate Cross-Origin Isolation Before Touching SharedArrayBuffer
Zero-copy pipelines that need genuinely concurrent access — audio graphs, physics, ring buffers between producer and consumer threads — reach for SharedArrayBuffer. Chrome exposes it only in cross-origin-isolated contexts, and the failure mode is a bare ReferenceError: SharedArrayBuffer is not defined that says nothing about headers.
Check three places, in this order:
- Network → the top-level document request → Response Headers. You need
Cross-Origin-Opener-Policy: same-originandCross-Origin-Embedder-Policy: require-corp. Areport-onlyvariant of either is not enough to enable isolation. - Application → Frames → top → Security & Isolation. Chrome reports Cross-Origin Isolated: true/false here, which is the state the runtime actually sees. If any embedded subresource lacks
Cross-Origin-Resource-Policyor CORS opt-in, this readsfalseeven with correct document headers. - The worker itself. Isolation is inherited by the worker, so assert it there rather than assuming.
undefined is the last link in this chain, not the first: correct document headers still yield false if one embedded subresource refuses to opt in.// main.ts — fail fast and fall back, instead of throwing deep inside the worker
if (!self.crossOriginIsolated) {
console.warn('Not cross-origin isolated: falling back to transferable buffers.');
}
const shared = self.crossOriginIsolated ? new SharedArrayBuffer(1024) : null;
if (shared) {
const flags = new Int32Array(shared);
Atomics.store(flags, 0, 0); // index 0 is the ready flag
const worker = new Worker(new URL('./sync.worker.ts', import.meta.url), {
type: 'module',
name: 'sync',
});
worker.postMessage({ type: 'ATTACH', shared });
worker.onmessage = () => {
console.log('worker wrote', Atomics.load(flags, 1));
};
}
// sync.worker.js — the worker inherits isolation; assert before use
if (!self.crossOriginIsolated) {
throw new Error('Worker started without cross-origin isolation');
}
self.onmessage = ({ data }) => {
if (data.type !== 'ATTACH') return;
const flags = new Int32Array(data.shared);
Atomics.store(flags, 1, 42); // write the result
Atomics.store(flags, 0, 1); // then publish readiness
Atomics.notify(flags, 0); // wake anyone blocked in Atomics.wait
self.postMessage({ type: 'SYNCED' });
};
Note the write order: publish the payload first, the readiness flag second. Atomics.store and Atomics.load are sequentially consistent, so a reader that observes the flag is guaranteed to observe the payload written before it. Getting that ordering wrong produces a race that no breakpoint will reliably reproduce.
SharedArrayBuffer requires Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp on the document. Without them the constructor is undefined in the page and in every worker it spawns, and enabling them will break third-party embeds (ads, maps, video) that do not send Cross-Origin-Resource-Policy. Verify self.crossOriginIsolated === true before writing any Atomics code.
Header-by-header diagnosis of the failure cases — credentialless vs require-corp, iframes, blob and data URLs — is the subject of Debugging SharedArrayBuffer Cross-Origin Errors. The synchronization patterns built on top of it live in SharedArrayBuffer & Atomics.
Choosing the Data-Transfer Mechanism
The trace from Step 4 tells you which mechanism your workload should be using. All three are debuggable in Chrome, but each fails differently, and that difference drives the choice.
| Mechanism | Strengths | Costs | Failure signature in DevTools |
|---|---|---|---|
| Structured clone | Handles cycles, Map, Set, Date, Blob; no ownership rules to get wrong |
Walks the whole graph on both sides; two full copies in memory at peak | Long sender task with little of your code in it; long leading gap in the receiving task |
| Transferable objects | Zero-copy; sub-millisecond regardless of size | Detaches the source; only ArrayBuffer, MessagePort, ImageBitmap, OffscreenCanvas, streams qualify |
TypeError on a detached buffer, or silent zero-length reads after a double send |
| SharedArrayBuffer | Both threads read and write the same memory; no hop at all for state updates | Requires cross-origin isolation; needs Atomics for every cross-thread ordering guarantee |
ReferenceError when isolation is missing; torn or stale reads when Atomics are skipped |
Practical routing:
- Control messages and config — structured clone. A few hundred bytes; the clarity is worth more than the microseconds.
- Bulk binary: audio frames, image data, vertex buffers, decoded columns — transferables. If the same buffer cycles between threads, transfer it back and forth rather than allocating a new one each round.
- Continuous shared state: ring buffers, progress counters, lock-free queues —
SharedArrayBufferwithAtomics, and only when you control the headers.
A common mixed design sends the schema and the job description as a cloned object, then streams the payload as a transferred buffer — cheap metadata, zero-copy bulk.
SharedArrayBuffer costs.Verification & Measurement
Confirm the fix rather than assuming it. Chrome’s panels give you the shape of the problem; these checks give you the number.
1. Measure the full round trip, correctly. Each worker has its own performance.timeOrigin, so a raw performance.now() value from a worker is not comparable to one from the page. Normalize through the epoch:
// main.ts — cross-thread timing that accounts for differing time origins
type Stamped = { sentAt: number; recvAt: number; doneAt: number };
const worker = new Worker(new URL('./parse.worker.ts', import.meta.url), {
type: 'module',
name: 'csv-parser',
});
function timedParse(csv: string): Promise<Stamped> {
return new Promise((resolve) => {
const sentAt = performance.timeOrigin + performance.now();
worker.addEventListener(
'message',
({ data }: MessageEvent<{ recvAt: number; doneAt: number }>) => {
const backAt = performance.timeOrigin + performance.now();
console.log({
serializeAndQueue: (data.recvAt - sentAt).toFixed(1), // hop out
computeInWorker: (data.doneAt - data.recvAt).toFixed(1),
hopBack: (backAt - data.doneAt).toFixed(1),
});
resolve({ sentAt, recvAt: data.recvAt, doneAt: data.doneAt });
},
{ once: true },
);
worker.postMessage({ type: 'PARSE', csv, batchId: 1 });
});
}
// parse.worker.js — stamp entry and exit with epoch-relative timestamps
self.onmessage = ({ data }) => {
const recvAt = performance.timeOrigin + performance.now();
const rows = data.csv.split('\n').map((line) => line.split(','));
const doneAt = performance.timeOrigin + performance.now();
self.postMessage({ type: 'RESULT', rows: rows.length, recvAt, doneAt });
};
serializeAndQueue is the number that a transferable refactor should collapse; computeInWorker is the number that only a better algorithm (or WebAssembly) will move.
2. Verify the isolate you profiled. In the Console, with the worker selected in the context dropdown, self.constructor.name must return "DedicatedWorkerGlobalScope". If it returns "Window", every measurement you just took was of the main thread.
3. Verify long tasks are gone, not relocated. After the refactor, re-record and confirm no main-thread task exceeds 50 ms during the workload. A worker task of 300 ms is fine; a main-thread task of 80 ms is not.
4. Verify memory returns to baseline. Repeat the Step 5 diff after the fix: heap size after forced GC should be within a few percent of the baseline snapshot, not monotonically higher.
5. Keep a regression trace. Save the “before” trace as JSON from the Performance panel’s export button and store it with the fix. It is the only cheap way to prove a later change did not undo the improvement.
Failure Modes & Error Handling
The worker never appears in the Threads pane. Either it terminated before you looked, the constructor’s URL failed (404, wrong MIME type, CSP worker-src violation), or it is a shared worker — those live in chrome://inspect/#workers, not the page’s Threads pane.
Breakpoints are hollow and never hit. The source map did not resolve, so the breakpoint has no location in the running script. Check the Page file tree for the original filename; if only the bundled .js is listed, the .map is missing or 404ing.
The worker dies silently. An uncaught throw inside a worker fires an ErrorEvent on the Worker object on the main thread — not window.onerror. Unhandled promise rejections fire unhandledrejection inside the worker and never reach the page at all unless you forward them:
// parse.worker.js — make every failure observable from the main thread
self.addEventListener('error', (event) => {
self.postMessage({ type: 'FATAL', error: serializeError(event.error) });
});
self.addEventListener('unhandledrejection', (event) => {
event.preventDefault(); // stop the default console warning
self.postMessage({ type: 'FATAL', error: serializeError(event.reason) });
});
// `stack` does not reliably survive structured clone; flatten to plain data.
function serializeError(err) {
if (!(err instanceof Error)) return { name: 'NonError', message: String(err) };
return {
name: err.name,
message: err.message,
stack: err.stack ?? null,
cause: err.cause ? String(err.cause) : null,
};
}
DataCloneError on postMessage. The payload contains something structured clone cannot copy — a function, a DOM node, a class instance with methods, a WeakMap, or a Proxy. The throw happens synchronously on the sender. Its mirror image is the messageerror event, which fires on the receiver when a message arrives but cannot be deserialized; listen for it, because otherwise that hop vanishes without trace.
A worker that will not die. worker.terminate() stops the thread immediately, mid-task, with no cleanup and no unload-style callback. If the worker holds an open IndexedDB transaction or a WritableStream, prefer sending a SHUTDOWN message, letting the worker close its resources and call self.close(), and keeping terminate() as a timeout-backed fallback.
Retry without a thundering herd. When a fatal error is reported, terminate, respawn, and replay only the in-flight message — with a cap. A worker that crashes on a specific payload will crash on it again, so after two attempts, drop the job and report it. The serialization contract for those reports is covered in Structured Error Serialization Across Threads, and the wider recovery patterns in Error Handling & Crash Recovery.
Browser Compatibility
| Capability | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
| Worker threads listed in the debugger | 38+ (Threads pane) | 99+ (Sources list) | 16+ | 79+ |
| Console execution-context picker for workers | 72+ | 56+ | Not available | 79+ |
| Per-worker heap snapshot (VM-instance selector) | 80+ | Partial (about:memory) | Not available | 80+ |
| Separate worker track in the performance profiler | 89+ | 55+ (Firefox Profiler) | Limited | 89+ |
postMessage initiator arrows in the trace |
126+ | Not available | Not available | 126+ |
| User timings from worker threads in the trace | 102+ | 55+ | Limited | 102+ |
SharedArrayBuffer behind COOP/COEP |
92+ | 79+ | 15.2+ | 92+ |
self.crossOriginIsolated |
87+ | 72+ | 15.2+ | 87+ |
Transferable ArrayBuffer |
17+ | 18+ | 6+ | 12+ |
Chrome’s advantage is memory tooling: no other browser exposes per-worker heap snapshots with a comparison view. Firefox’s advantage is the profiler, whose per-thread filtering and shareable profile URLs are better suited to worker CPU work — the equivalent workflow is documented in Firefox Worker Debugging, and the panel-by-panel differences in Comparing Chrome and Firefox Worker Tooling. In practice: attribute the time in Chrome, verify leaks in Chrome, and reach for Firefox when the flame chart is the artefact you need to share. When the bug only reproduces for real users, move the same instrumentation into Production Error Telemetry.
Going Further
Workers built from a string need a little help before any of this applies: a blob URL changes on every load, so breakpoints do not survive a reload and stack traces cite an identifier that means nothing. Setting Breakpoints in Blob and Inline Workers covers the two lines that fix it, source maps for generated code, and how to inspect a worker shipped by a third-party library.