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:

  1. 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.
  2. 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.
  3. 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.

Three suspects behind residual jank, and the panel that proves each one One symptom — a parse moved into a worker that still stalls the UI for 150 to 200 milliseconds — branches into three suspects: boundary cost, proved by a long sender task in the Performance panel; worker CPU, proved by a long worker task while the main thread idles; and retention, proved by a positive heap delta after forced garbage collection in the Memory panel. Parse moved to a worker — UI still stalls 150–200 ms correct rows, no errors, nothing in the console 1 · Boundary cost the payload is copied twice serialize, then deserialize Trace: long sender task holding little of your code proved in Step 4 2 · Worker CPU the algorithm itself is slow the move only hid it Trace: long worker task while the main thread idles proved in Step 3 3 · Retention the worker keeps every batch in a cache nobody clears Memory: positive delta after a forced collection proved in Step 5 Each suspect is settled by a different panel — and only when that panel is pointed at the worker isolate.
The same symptom has three causes, and each leaves a different fingerprint: boundary cost shows up as a fat sender task, worker CPU as a long worker task beside an idle main thread, and retention as heap that survives a forced garbage collection.

Prerequisites

  • Chrome 90 or later. Earlier builds lack the per-thread selector in the Memory panel; Chrome 126+ additionally draws postMessage initiator 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 //# sourceURL annotation 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 SharedArrayBuffer step: the document must be served with Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp. Local file:// URLs cannot satisfy this; serve over http://localhost or HTTPS.
Which DevTools surface answers which question Six steps mapped to the panel each one uses: the Sources Threads pane to attach to the isolate, Event Listener Breakpoints to pause during startup, the Performance recording for per-worker tracks, the Performance initiator arrow for clone cost, the Memory VM-instance selector for a heap diff, and the Network and Application panels for COOP, COEP and the isolation state. One workflow, four panels Sources › Threads attach to the worker isolate Step 1 Event Listener Breakpoints pause on startup, or on a message Step 2 Performance › record one track per live worker Step 3 Performance › initiator clone cost of a single hop Step 4 Memory › VM instance diff the worker's own heap Step 5 Network + Application COOP / COEP and isolation Step 6
The six steps below in the order they are worked, each labelled with the panel it lives in — attribution first in Sources and Performance, then Memory for retention, then Network and Application for isolation.

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.

The Threads pane selects which isolate DevTools is attached to The Sources Threads pane lists the main frame and two live workers. Selecting the csv-parser entry points the file tree, call stack, scope pane and console context at that worker's isolate; a breakpoint set while the main frame is selected never fires in worker code. Sources › Threads top — app.html main frame csv-parser selected debug target image-resizer running, not attached One target at a time. The pane lists every live dedicated worker, labelled by its name or script URL. Main frame isolate — window, document, DOM a breakpoint set here never fires in worker code, even in the same module csv-parser isolate — DedicatedWorkerGlobalScope own heap · own event loop · own breakpoints file tree, Call Stack and Scope all follow this selection Console context dropdown evaluates here, including Live Expressions
Clicking a Threads entry re-points every debugger surface at that isolate. Until you do, the file tree, breakpoints and Console are still describing the main frame.
// 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.

Breakpoints do not pause sibling threads

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:

  1. 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 a import.meta.env.DEV check.
  2. 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.
  3. 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.
Where each startup breakpoint lands in a worker's life cycle A worker's startup runs from the Worker constructor through script fetch, top-level evaluation and the onmessage assignment to the first delivered message. A debugger statement or the Script First Statement breakpoint pauses during top-level evaluation; the Worker message event listener breakpoint pauses on the first delivered message. Everything before that is over by the time you click into the Threads pane. Catch the startup window debugger; on line 1, or Script First Statement Catch the payload Worker → message breakpoint new Worker() returns at once fetch script network, CSP top-level eval module body runs onmessage set handler installed first message payload arrives over before you can click into the Threads pane the first hop you can catch
The interesting failures — a half-populated config, a 404 on 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.

A failed constructor looks like silence

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:

  1. 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.
  2. Click record, trigger exactly one workload run, stop. Traces longer than about 10 seconds get hard to read; one run is enough.
  3. Find the worker track — it is labelled with the script URL, or with the name you passed to the constructor.
  4. 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.

Throttle, but interpret honestly

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 Call block 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);
};
Transfer removes the copy and adds a lifecycle rule

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.

Reading one postMessage hop out of a Performance trace Two tracks stacked in the Performance panel. On the main-thread track a click-handler task contains a shaded structured-clone block billed to postMessage. An initiator arrow crosses to the worker track, where the Event message task opens with a deserialization gap before the first user-timing mark, followed by the labelled split and fields measure spans. time → Main thread cannot paint Task · click handler your code: 6 ms postMessage serialize ≈ 34 ms initiator arrow · Chrome 126+ Worker csv-parser idle — nothing to run yet Event: message · 190 ms deserialize ≈ 48 ms split 12 ms fields 96 ms the leading gap, before the first mark, is deserialization Structured clone is never its own entry: it is billed inside the sending and the receiving task.
One 12 MB hop, drawn as the panel shows it. The dashed blocks are the clone halves — 34 ms charged to the sender's task and 48 ms charged to the receiver's, neither of them labelled as such.

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:

  1. Warm up: run the workload once, then click Collect garbage (the trash icon). This clears one-shot allocations so your baseline is not noise.
  2. Take snapshot 1 (baseline).
  3. Run the workload five to ten times.
  4. Click Collect garbage again, then take snapshot 2.
  5. 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.

Diffing two heap snapshots taken on the worker isolate The Memory panel's VM-instance selector offers the main frame, whose snapshot holds document objects only, and the csv-parser worker, the isolate that must be selected. Below it the five-step procedure runs from a forced collection through a baseline snapshot, repeated workload, second snapshot and the Comparison view sorted by delta; the retainer chain then reads from the parsed rows through a module-scope Map up to the worker global. Memory › JavaScript VM instance top — app.html document heap only csv-parser the isolate you must pick One snapshot captures one isolate main frame selected → the worker's objects are simply absent worker selected → its retained graph, without document noise Collect warm-up Snapshot 1 baseline run workload 5–10 times Collect · Snapshot 2 forced collection first Comparison sort by Delta Retainer chain — each box is held by the one to its right, up to the worker global: parsed rows[] +48 MB over 10 runs cache (Map) module scope WorkerGlobalScope GC root — never torn down
The selector at the top of the Memory panel decides whose heap you are looking at; everything below it — baseline, repeat, forced collection, Comparison by delta — only means anything once the worker is the selected VM instance.
// 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.

Snapshots pause the isolate they measure

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:

  1. Network → the top-level document request → Response Headers. You need Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp. A report-only variant of either is not enough to enable isolation.
  2. 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-Policy or CORS opt-in, this reads false even with correct document headers.
  3. The worker itself. Isolation is inherited by the worker, so assert it there rather than assuming.
What has to hold before SharedArrayBuffer exists The top-level document must send Cross-Origin-Opener-Policy same-origin and Cross-Origin-Embedder-Policy require-corp, and report-only variants do not count. Every subresource must then send CORP or opt in via CORS: if it does, crossOriginIsolated is true and SharedArrayBuffer is available and inherited by workers; if any does not, crossOriginIsolated is false and the constructor is undefined. Top-level document COOP: same-origin COEP: require-corp report-only does not count every subresource CORP or CORS? yes no crossOriginIsolated === true SharedArrayBuffer is available isolation is inherited by workers Application › Frames › top confirms crossOriginIsolated === false SharedArrayBuffer is undefined — a bare ReferenceError assert self.crossOriginIsolated in the page and inside the worker Isolation is a property of the document; a worker only inherits what the document already has.
The constructor being 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.

COOP / COEP required for SharedArrayBuffer

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 queuesSharedArrayBuffer with Atomics, 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.

Choosing between structured clone, transferables and SharedArrayBuffer A decision tree keyed on the payload. A few hundred bytes of config or control leads to structured clone, which handles cycles, Map, Set, Date and Blob at the cost of a full graph walk on both sides. Megabytes of binary data lead to transferables, where ownership moves in under a tenth of a millisecond but the source buffer detaches. State that both threads read and write continuously leads to SharedArrayBuffer, which removes the hop entirely but requires cross-origin isolation and Atomics. What is in the payload? the Step 4 trace answers this A few hundred bytes of config or control Megabytes of binary: audio, images, vertices State both threads read and write live Structured clone cycles, Map, Set, Date, Blob cost: the graph walked twice Transferable ownership moves, < 0.1 ms cost: the source detaches SharedArrayBuffer no hop at all for state needs COOP/COEP + Atomics Mixed design: clone the job description, transfer the bulk buffer it points at.
Routing by payload rather than by habit: control messages stay cloned, bulk binary moves by ownership, and only continuously shared state justifies the cross-origin isolation that 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.

Division of labour between Chrome and Firefox worker tooling Both browsers list worker threads in the debugger. Chrome is the only one with per-worker heap snapshots and a Comparison view, postMessage initiator arrows from version 126, and a console execution-context picker. Firefox answers with per-thread filtering in its profiler and shareable profile URLs, but has no per-worker heap snapshot. Both browsers list worker threads; the useful difference is what comes after Chrome — attribution and memory per-worker heap snapshots, Comparison view postMessage initiator arrows (126+) console execution-context picker no other engine ships the heap comparison Firefox — filtering and sharing per-thread filtering in the Profiler shareable profile URLs worker scopes listed in Sources no per-worker heap snapshot Attribute the time in Chrome · verify leaks in Chrome · share the flame chart from Firefox
Where each browser earns its place in the workflow: Chrome owns attribution and retention, Firefox owns the profile you hand to someone else.

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.

Frequently Asked Questions

How do I set a breakpoint inside a Web Worker in Chrome DevTools?
Open Sources, find the Threads pane in the left sidebar, and click the worker entry to make it the active debug target. Then open its script from the file tree and click the line gutter as usual. Pausing that worker does not pause the main thread or any sibling worker, so the paused worker’s message queue keeps growing while you inspect — that is expected, not a bug.
My worker never appears in the Threads pane. What is wrong?
Three usual causes: the worker was created and terminated before you looked; the constructor threw (a 404 on the script URL fires an error event on the Worker object, not a console exception); or you are looking at a shared worker, which lives in chrome://inspect/#workers rather than the page’s Threads pane. To catch a worker that starts during page load, enable Event Listener Breakpoints → Script → Script First Statement before reloading, or put a debugger; statement on the first line of the worker script.
How do I take a heap snapshot of one specific worker in Chrome?
The Memory panel snapshots one JavaScript VM instance at a time. Before clicking Take snapshot, pick the worker in the VM-instance selector at the top of the panel (older builds label it Select JavaScript VM instance; newer builds show it as a Threads dropdown). Snapshotting from the default main-frame context gives you the document heap only — the worker’s retained objects are simply not in that graph.
Why is SharedArrayBuffer undefined inside my worker?
The document must be cross-origin isolated: Cross-Origin-Opener-Policy: same-origin plus Cross-Origin-Embedder-Policy: require-corp on the top-level document response, and every subresource must be same-origin or serve Cross-Origin-Resource-Policy / opt in via CORS. Confirm with self.crossOriginIsolated === true — it must be true on both the page and inside the worker. DevTools reports the same state under Application → Frames → top → Security & Isolation.
Where does structured clone time actually show up in a Performance trace?
Serialization is billed to the task that calls postMessage on the sending thread, and deserialization to the task that delivers the event on the receiving thread — the cost is inside those tasks, not a separate top-level entry. In Chrome 126 and later the panel draws an initiator arrow from the postMessage event on the sender to the handler entry on the receiver, which is the fastest way to see both halves of one hop.

See also