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.
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.
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 to → Snapshot 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.
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.
# 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.
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.
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.
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.