Heap Snapshot Diffing for Worker Leaks

A worker that grows by a few kilobytes per message is invisible on the main thread — this page shows how to snapshot the worker’s own V8 isolate, diff three captures, and name the exact variable that is holding the garbage.

The procedure below is the hands-on capture step inside Identifying Memory Leaks in Workers, the leak-hunting protocol within Debugging, Profiling & Production Optimization. It assumes you can already attach DevTools to a worker thread; if the worker never appears in the target selector at all, fix that first with Chrome DevTools Worker Debugging.

One isolate, one heap

Each dedicated worker gets its own V8 isolate, its own garbage collector and its own heap. Nothing in the page's snapshot describes it. The Worker handle on the main thread has a retained size of a few hundred bytes whether the worker is holding 2 MB or 2 GB — which is exactly why worker leaks reach users before anyone notices them.

Minimal Reproducible Example: A Worker That Leaks Listeners

The smallest complete leak worth diffing is a subscription handler that is appended and never removed. It is realistic — nearly every worker-backed pub/sub layer contains this shape — and it produces a clean, unambiguous signature in the Comparison view.

// leaky-worker.ts — DO NOT SHIP THIS
type Subscriber = (data: unknown) => void;

const handlers: Subscriber[] = []; // module scope: lives as long as the worker

self.onmessage = (e: MessageEvent<{ type: string; payload?: unknown }>) => {
  if (e.data.type === 'subscribe') {
    // BUG: every subscribe appends a closure that is never released.
    const handler: Subscriber = (data) => {
      self.postMessage({ event: 'update', data });
    };
    handlers.push(handler);
  }
};
// main.ts — the driver used for every work cycle
const worker = new Worker(new URL('./leaky-worker.ts', import.meta.url), { type: 'module' });

export function runCycle(n = 10): void {
  for (let i = 0; i < n; i++) worker.postMessage({ type: 'subscribe' });
}

Two properties make this a good test case. The leaked object is a closure, so it also drags its lexical scope along; and handlers is module scope, which in a module worker is retained for the lifetime of the isolate. After a hundred subscribe messages the worker holds a hundred closures plus the backing store growth of the array — small individually, unbounded in aggregate.

What the leak actually looks like on each side of the boundary Two panels separated by a dashed postMessage boundary. On the left, the main thread posts four identical subscribe messages per cycle, and its Worker handle is drawn as one small fixed box with a retained size of about 312 bytes that never changes. On the right, the worker isolate receives each message in self.onmessage, which allocates one closure and pushes it into a module-scoped handlers array. The array is drawn as a row of cells, each holding a closure plus a nested captured-scope context object of roughly 120 bytes, with an ellipsis before handlers index 99 to show unbounded growth. Nothing on the left grows; everything that grows is on the right. Main thread Worker isolate — separate V8 heap postMessage({ type: 'subscribe' }) postMessage({ type: 'subscribe' }) postMessage({ type: 'subscribe' }) postMessage({ type: 'subscribe' }) 10 identical messages per cycle Worker handle retained size ≈ 312 B the same after 1 message and after 100 000 self.onmessage allocates one closure, pushes it const handlers: Subscriber[] module scope — retained until terminate() (closure) (closure) (closure) (closure) (closure) system / Context system / Context system / Context system / Context system / Context ≈120 B ≈120 B ≈120 B ≈120 B ≈120 B handlers[0] handlers[1] handlers[2] handlers[3] handlers[99] # Delta per cycle: (closure) +10 · system / Context +10 the Array backing store grows too — and nothing is ever removed postMessage boundary — two isolates, two heaps, two garbage collectors
The asymmetry is the whole problem: the left panel is what a main-thread profile can see, and it is constant. Every object the leak creates lives on the right, in a heap the page's snapshot does not contain.

Step-by-Step Walkthrough: Diffing the Worker’s Own Heap

1. Select the worker’s JavaScript VM instance

Open DevTools and go to Memory. At the top of the panel, the JavaScript VM instance selector defaults to Main — the page’s isolate. Change it to the worker, which appears as its script URL (leaky-worker.ts, or blob:https://… for an inline worker). If the worker is not listed, it has not started yet: post one message to it from the console while the Memory panel is open and the entry appears.

Every snapshot you intend to compare must be captured with the same VM instance selected. DevTools will happily let you diff a worker snapshot against a main-thread snapshot, and the resulting deltas are meaningless.

2. Quiesce the worker, collect garbage, take the baseline

Snapshotting a worker mid-computation captures transient allocations — live task objects, in-flight promise reactions — that inflate # New and manufacture false positives. Drain it first with a ping-pong round trip, then click the Collect garbage (trash can) icon before the capture:

// drain.ts — resolve once the worker has processed everything queued before the ping
export function drain(worker: Worker): Promise<void> {
  return new Promise((resolve) => {
    const onPong = (e: MessageEvent<{ type: string }>) => {
      if (e.data.type !== 'PONG') return;
      worker.removeEventListener('message', onPong); // do not leak the drainer itself
      resolve();
    };
    worker.addEventListener('message', onPong);
    worker.postMessage({ type: 'PING' });
  });
}

Take snapshot 1 and label it baseline.

3. Run one cycle, then a second, snapshotting after each

Call runCycle(10), await drain(worker), collect garbage, take snapshot 2. Repeat the identical sequence and take snapshot 3. The cycles must be identical — same message count, same payload sizes — or the deltas describe your inputs rather than the worker’s retention.

4. Open the Comparison view and rank the deltas

Select snapshot 3 in the profile list, change the view selector from Summary to Comparison, and set Compare toSnapshot 2. Sort by # Delta descending. For the example above the top row is (closure) with # Delta = +10 — exactly the number of subscribe messages in the cycle, which is the fingerprint of a per-message leak.

5. Walk the retainer chain

Expand (closure), select any instance, and read the Retainers pane at the bottom of the panel. It reads bottom-up from the object to its root:

(closure) @1284993
  └── in handlers[9]           Array @1284801
      └── in handlers          system / Context @1284700
          └── in DedicatedWorkerGlobalScope @1

That chain is the answer. It names the variable (handlers), its container (an Array), and the root that keeps it alive (the worker global). A leak is only fixed when you can point at the edge in this chain that should have been removed — here, the missing handlers.splice(index, 1) on unsubscribe.

Why two snapshots cannot tell a cache from a leak Across the top, a five-stage timeline: snapshot 1 taken as a baseline after garbage collection, a first runCycle plus drain, snapshot 2, a second identical runCycle, and snapshot 3. An arrow beneath points from snapshot 3 back to snapshot 2, the pair actually diffed. Below, a plot shows two traces over the same three capture points. The leak trace climbs by the same 500-object delta on both intervals. The warm-up cache trace climbs by 500 on the first interval and by zero on the second, flattening. Both look identical at snapshot 2, which is why the third capture is what separates them. Three snapshots, two identical cycles — and what separates a cache from a leak Snapshot 1 baseline · after GC runCycle(10) drain + GC Snapshot 2 after cycle A runCycle(10) identical input Snapshot 3 after cycle B the diff that decides: snapshot 3 vs snapshot 2 leaking worker — the same delta on every interval warm-up cache — grows once, then flat Δ +500 closures Δ +500 again → leak Δ +500 (first use) Δ 0 → warm-up, not a leak reference point both curves grew — inconclusive only the leak grew again
Snapshot 2 is the trap: at that point a first-use cache and a real leak are indistinguishable. Only the second identical cycle separates them, which is why the procedure has three captures and not two.

Reading the Comparison View: Delta, Shallow and Retained

The Comparison view answers two different questions with two different sets of columns, and conflating them is the most common misreading.

Column Meaning
Constructor Object type — Array, (closure), ArrayBuffer, Map, system / Context
# New Objects allocated since the comparison snapshot
# Deleted Objects freed since the comparison snapshot
# Delta # New − # Deleted; a persistently positive value is the leak signal
Alloc. Size / Freed Size Bytes allocated and released between the two snapshots
Size Delta Net bytes retained across the interval

Shallow size is the memory of the object itself — its header and own slots. Retained size is everything that would be freed if the object were collected, following every edge that leads only through it. A Map with 10 000 entries has a shallow size of a few dozen bytes and a retained size in the megabytes; the shallow number tells you almost nothing about the damage.

Comparison view, snapshot 3 vs snapshot 2:
  Constructor    | # Delta | Shallow Δ | Retained Δ
  (closure)      |   +100  |    +8 KB  |   +4.2 MB   ← few objects, huge retention
  string         |   +500  |   +50 KB  |    +50 KB   ← shallow ≈ retained, harmless
  system/Context |   +100  |   +12 KB  |   +4.2 MB   ← the captured scopes themselves

Rank by count delta to find what is leaking; re-rank by retained size to decide whether it matters. A hundred closures each pinning a 40 KB typed array outrank five hundred short strings by two orders of magnitude.

Reading the Comparison view: which column answers which question A schematic of the DevTools Memory panel with the view set to Comparison, compared against snapshot 2, and the VM instance set to the worker script. The table has columns for Constructor, number New, number Deleted, number Delta, Shallow Delta and Retained Delta. Three rows are shown: closure with a delta of plus 100, 8 kilobytes shallow and 4.2 megabytes retained; system slash Context with the same shape; and string with a delta of plus 500 but only 50 kilobytes retained. The number Delta column is outlined as the leak signal, and a second callout contrasts the tiny shallow size with the large retained size on the same row. Beneath, the Retainers pane traces the selected closure through handlers index 9, then the handlers Array, then a system Context, and finally to DedicatedWorkerGlobalScope, the GC root. Snapshot 3 compared against snapshot 2 Memory — Comparison View: Comparison Compare to: Snapshot 2 VM: leaky-worker.ts Constructor # New # Deleted # Delta Shallow Δ Retained Δ (closure) 100 0 +100 +8 KB +4.2 MB system / Context 100 0 +100 +12 KB +4.2 MB string 620 120 +500 +50 KB +50 KB +100 on every cycle = the leak signal 8 KB shallow pins 4.2 MB retained size is the damage Retainers read bottom-up: object → GC root (closure) @1284993 in handlers[9] — Array in handlers — system / Context DedicatedWorkerGlobalScope
Two questions, two columns: # Delta ranks what is leaking, retained size ranks whether it matters. The row with 500 new strings is noise; the row with 100 new closures is 4.2 MB.

Retainer Signatures of the Four Common Worker Leaks

Each leak shape produces a recognisable constructor and chain. Learning the four saves you from re-deriving them under production pressure.

Retained transferred buffers. After postMessage(payload, [buffer]) the worker owns the bytes; the main thread’s view is detached. Ownership semantics are covered in Transferable Objects & Zero-Copy — the leak is what the worker does with them afterwards:

// leaky-worker-buffer.ts — DO NOT SHIP THIS
const processed: ArrayBuffer[] = [];

self.onmessage = (e: MessageEvent<ArrayBuffer>) => {
  processed.push(e.data); // BUG: never evicted
  self.postMessage('ok');
};

Signature: ArrayBuffer with positive # Delta and a retained-size delta equal to the payload bytes; retainer chain ArrayBuffer ← processed[n] ← processed ← global. The fix is to let the parameter fall out of scope after processing, or to transfer the buffer back to the caller so ownership leaves the worker entirely.

Uncleared timers. A setInterval that accumulates samples shows as Array with a growing retained size and number/HeapNumber counts climbing in lockstep. The interval callback is itself retained by the timer, so the array is reachable even when nothing else references it. Fix with clearInterval on completion, or bound the buffer to a rolling window.

addEventListener where you meant onmessage. Assigning port.onmessage = handler replaces the previous handler; port.addEventListener('message', handler) appends to a list. Re-running setup on every reconnect therefore multiplies handlers, and the signature is (closure) growing by exactly one per setup call with the retainer chain ending in an EventListener list on a MessagePort. Either use the property form, or keep the handler reference and call removeEventListener on teardown.

Scopes captured across await. V8 keeps a variable alive in an async function’s context until the function returns if any resumption point can still observe it:

// Leaky: bigArray stays reachable across every await in this function
async function processChunk(bigArray: Float32Array): Promise<void> {
  const summary = await step1(bigArray);
  await step2(summary);       // bigArray is still in the context here
}

// Fixed: the large buffer never enters the long-lived context
async function processChunkFixed(getChunk: () => Float32Array): Promise<void> {
  const summary = await step1(getChunk()); // temporary dies with the call frame
  await step2(summary);
}

Signature: system / Context objects with a large retained size, whose retainer chain runs through a Promise reaction. The reliable fix is structural — pass a summary or a view downstream instead of carrying the full buffer through the chain.

Four leak shapes and the retainer chain each one prints A four-row matrix. Row one, retained buffers: constructor ArrayBuffer, chain ArrayBuffer back to processed index n, back to processed, back to global; the fix is to let the parameter fall out of scope or transfer it back out. Row two, uncleared timers: constructor Array, chain Array back to samples, back to the interval callback, back to the timer; the fix is clearInterval or a bounded rolling window. Row three, appended listeners: constructor closure, chain closure back to a listener list, back to a MessagePort, back to global; the fix is to use port.onmessage or pair every addEventListener with a removeEventListener. Row four, scopes captured across await: constructor system slash Context, chain Float32Array back to system Context back to a Promise reaction; the fix is to pass a summary downstream instead of the whole buffer. Leak shape Comparison view Retainer chain (bottom-up) One-line fix Retained buffers the worker owns the bytes after a transfer and never evicts them ArrayBuffer +1 per payload ArrayBuffer ← processed[n] ← processed ← global retained Δ equals the payload bytes exactly Let it fall out of scope, or transfer it back out. Uncleared timers a setInterval that appends a sample on every single tick Array + HeapNumber Array ← samples ← interval callback ← timer the timer keeps the callback reachable by itself clearInterval on completion, or cap a rolling window. Appended listeners addEventListener where you meant the onmessage property (closure) +1 per setup call (closure) ← listener list ← MessagePort ← global the delta counts reconnects, not messages Use port.onmessage, or pair every add with a remove. Scopes across await a large buffer stays in the async context until the function returns system / Context few objects, huge Δ Float32Array ← system / Context ← Promise reaction shallow size stays tiny, retained size explodes Pass a summary downstream, never the whole buffer.
Four chains, four fixes. Under production pressure you are matching a shape, not deriving one — the constructor at the top of the Comparison view already tells you which of these four rows you are in.

Gotchas & Edge Cases

The worker VM vanishes when the worker dies. Calling worker.terminate() before the final capture removes the entry from the VM instance selector and takes the isolate’s heap with it. Keep the worker alive for the whole session; if you need to observe teardown, call self.close() from inside the worker instead — the context lingers briefly in DevTools before collection.

WeakRef and WeakMap entries are not stable diff subjects. Objects held only weakly may or may not be present depending on when GC last ran, so they oscillate between snapshots and produce noisy deltas in both directions. Always click Collect garbage immediately before each capture; a weak cache that still shows growth after a forced GC has a strong reference hiding somewhere else in the chain.

Module-scope state is permanent for the isolate’s lifetime. In a { type: 'module' } worker, top-level bindings are held by the module namespace, which the worker global retains until termination. A module-level const cache = new Map() is therefore a leak by default unless it is explicitly bounded — an LRU with a hard entry cap, or a WeakMap keyed on message-correlated objects.

Snapshotting is not free, and it distorts what follows. Taking a snapshot forces a full GC and walks the entire heap; on a 200 MB worker heap that is several hundred milliseconds during which the worker is stopped. Never interleave heap captures with a CPU profiling run — take timings separately, as described in Profiling Worker CPU Usage with the Chrome Performance Tab.

Triaging an ambiguous diff, one question at a time A decision tree with three questions. First: does the count delta grow between snapshots 2 and 3 on identical cycles? If no, it is a warm-up allocation — a first-use cache, a lazy regex or JIT tier-up — and not a leak. If yes, second question: is it still there after clicking Collect garbage before each capture? If no, it is a garbage-collection timing artefact, typical of WeakRef and WeakMap entries that oscillate between captures. If yes, third question: what does the retainer chain end in? Four answers, each with its fix. Module scope, a top-level Map or Array held until terminate, is fixed by capping it with an LRU or a WeakMap. A timer callback, where setInterval retains the closure and its samples, is fixed by clearInterval on teardown. A listener list, one closure appended per setup call on a MessagePort, is fixed with the onmessage property or removeEventListener. A Promise context, a buffer kept alive across every await until the function returns, is fixed by passing a summary instead of the buffer. # Delta grows from snapshot 2 to 3? the same cycle, replayed identically Warm-up allocation first-use cache, lazy regex, JIT tier-up not a leak — stop here no yes Still there after Collect garbage? force GC immediately before every capture GC timing artefact WeakRef / WeakMap entries oscillate re-capture after a forced GC no yes What does the retainer chain end in? expand the constructor, read the Retainers pane Module scope a top-level Map or Array held until terminate() Cap it: LRU or WeakMap Timer callback setInterval retains the closure and its samples clearInterval on teardown Listener list one closure appended per setup call on a port Use the onmessage property Promise context a buffer kept alive across every await until return Pass a summary, not the buffer
Most ambiguous diffs die at one of the first two questions. Only growth that survives an identical second cycle and a forced GC is worth the cost of walking a retainer chain.

Performance Note: What Each Leak Shape Costs Per Cycle

The two leak classes fail on completely different timescales, and the numbers decide how hard you chase them.

A listener leak accumulating one closure plus its context per message costs roughly 120 bytes per subscription in V8. At 10 messages per second that is about 1 MB of retained heap after 10 minutes — slow enough to survive every QA session and still crash an all-day dashboard tab. A buffer-accumulation leak in an image worker holding 1 MB frames at 30 fps reaches 1.8 GB in one minute and hits V8’s default heap ceiling long before that, killing the worker with an out-of-memory error the main thread only sees as a silent error event.

The practical rule of thumb: any constructor whose # Delta scales linearly with the number of messages in a cycle is a leak, regardless of how small each object is. Bytes per message multiplied by session length is the only number that matters, and sessions in worker-backed apps are measured in hours.

Two leak profiles against the heap ceiling, on logarithmic axes Both axes are logarithmic, so a constant per-event leak plots as a straight line. The horizontal axis runs from one second to eight hours; the vertical axis from 100 kilobytes to about 2 gigabytes. A dashed line marks the V8 old-space ceiling at roughly 1.5 gigabytes on desktop, and a second dashed line marks a 512 megabyte ceiling on a low-end device. The buffer leak, one megabyte per frame at thirty frames per second, retains about thirty megabytes every second: it passes the 512 megabyte line at about seventeen seconds and hits the desktop ceiling at about forty-eight seconds, killing the worker. The listener leak, one hundred and twenty bytes per message at ten messages per second, only crosses one hundred kilobytes after about eighty seconds, reaches about one megabyte after ten minutes and about thirty-five megabytes after an eight-hour session, paying in constant major garbage-collection pressure rather than an immediate crash. Retained worker heap vs session duration (log–log) V8 old-space ceiling ≈1.5 GB (desktop) 512 MB ceiling on a low-end device Buffer leak — 1 MB frame at 30 fps ≈30 MB retained every second crosses 1.5 GB at ≈48 s — the worker is killed Listener leak — 120 B × 10 msg/s ≈1 MB after 10 min, ≈35 MB after 8 h the cost is major-GC pressure all day 100 KB 1 MB 10 MB 100 MB 1 GB 1 s 10 s 1 min 10 min 1 h 8 h session duration — a constant per-event leak is a straight line on log–log axes
Four orders of magnitude separate the two failure modes, and both are the same bug class. The slope is what you control: bytes per message multiplied by session length is the only number that decides which line you are on.

Automating the Diff for CI

Manual snapshotting finds a leak once; a CI check stops it coming back. performance.measureUserAgentSpecificMemory() reports per-context byte totals with worker attribution, so you can assert on growth across identical cycles without opening DevTools:

// leak-guard.ts — run on the main thread, after each identical work cycle
interface MemoryBreakdown {
  bytes: number;
  attribution: Array<{ url?: string }>;
}

async function workerBytes(match: string): Promise<number> {
  const api = (performance as Performance & {
    measureUserAgentSpecificMemory?: () => Promise<{ breakdown: MemoryBreakdown[] }>;
  }).measureUserAgentSpecificMemory;
  if (!api) return NaN; // Chromium-only; skip the assertion elsewhere

  const { breakdown } = await api.call(performance);
  return breakdown
    .filter((b) => b.attribution.some((a) => a.url?.includes(match)))
    .reduce((sum, b) => sum + b.bytes, 0);
}

// Fail the build if cycle 3 retains materially more than cycle 2.
const afterTwo = await workerBytes('leaky-worker');
await runCycleAndDrain();
const afterThree = await workerBytes('leaky-worker');
if (afterThree > afterTwo * 1.05) throw new Error('worker heap grew across identical cycles');

The API is gated behind cross-origin isolation: the page must be served with Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp, the same requirement as SharedArrayBuffer. If your CI harness serves pages without them the call is simply absent — see Debugging SharedArrayBuffer Cross-Origin Errors for the header setup, and fall back to manual snapshots where you cannot set them. Note also that results are deliberately coarse and delayed by the implementation to avoid a timing side channel, so compare across cycles rather than trusting any single absolute figure.

Turning the three-snapshot procedure into a build assertion A flow that begins with a gate: is the page cross-origin isolated, served with COOP same-origin and COEP require-corp, so that self.crossOriginIsolated is true? On the yes branch, the job runs cycle N with identical messages and awaits drain, calls measureUserAgentSpecificMemory, and sums the bytes of every breakdown entry whose attribution url matches the worker, producing one number per cycle. A loop arrow shows the cycle repeated so there is a sample after cycle 2 and after cycle 3. The two samples, 41.3 and 46.1 megabytes, give a ratio of 1.12, and the assertion fails the build when afterThree exceeds afterTwo times 1.05, with a reminder that results are deliberately coarse and delayed so only ratios should be compared. On the no branch, the API is simply absent and the job falls back to manual snapshot diffing, or to fixing the headers — the same gate SharedArrayBuffer needs. repeat: cycle 2, then cycle 3 — identical input Cross-origin isolated? COOP: same-origin COEP: require-corp self.crossOriginIsolated yes no Run cycle N identical messages, then await drain(worker) measureUserAgent- SpecificMemory() breakdown[] with attribution Sum bytes where the attribution url matches one number per cycle two samples, one per cycle afterTwo 41.3 MB afterThree 46.1 MB ratio 1.12 afterThree > afterTwo × 1.05 → fail the build results are coarse and delayed by design — compare ratios only API is absent the method simply is not there Fall back to manual snapshot diffing or set the headers — the same gate SharedArrayBuffer needs
The assertion is a ratio, never an absolute: the API deliberately coarsens and delays its numbers to close a timing side channel, so only the comparison between two identical cycles carries signal.

Frequently Asked Questions

Why must I select the worker VM instance in the Memory panel — can't I just snapshot the main thread?
Every worker runs in its own V8 isolate with a completely independent heap, so a main-thread snapshot contains none of the worker’s objects: not the closures it registered, not the ArrayBuffer it took ownership of after a transfer, not the timers it never cleared. The Worker object you see on the main thread is a thin handle whose retained size is a few hundred bytes no matter how many megabytes the worker is holding. You must switch the JavaScript VM instance selector at the top of the Memory panel to the worker’s context before clicking Take snapshot, and you must keep it selected for every snapshot in the comparison set.
How many snapshots do I need to prove a leak rather than a warm-up allocation?
Three. Two snapshots only tell you that something grew, which a first-use cache, a lazily compiled regex or a JIT warm-up will also do. Take a baseline, run one identical work cycle, snapshot again, run the same cycle again, snapshot a third time. A cache shows # Delta = +500 from snapshot 1 to 2 and 0 from 2 to 3; a real leak shows the same positive delta on both intervals, scaling with the number of cycles. Click Collect garbage before each capture so the diff is not polluted by objects that were already unreachable.

See also