Heap Snapshot Diffing for Worker Leaks
Worker memory leaks are invisible on the main thread. Because each worker runs in an isolated V8 heap, Chrome DevTools’ main-thread snapshot misses every byte of retained worker memory. You must snapshot the worker’s own isolate and compare across cycles.
This page is part of the Identifying Memory Leaks in Workers section within the Debugging, Profiling & Production Optimization reference.
Minimal Reproducible Example: A Worker with a Listener Leak
// leaky-worker.ts — DO NOT SHIP THIS
const handlers: Array<(data: unknown) => void> = [];
self.onmessage = (e: MessageEvent) => {
if (e.data.type === 'subscribe') {
// BUG: handler is appended but never removed
const handler = (data: unknown) => {
self.postMessage({ event: 'update', data });
};
handlers.push(handler);
// "handlers" grows with every subscribe message — memory leak
}
};
After a hundred subscribe messages, handlers holds a hundred closures, each retaining its own scope chain. A heap snapshot comparison will show Array and Function counts growing across cycles.
Step-by-Step: Taking Worker Heap Snapshots in Chrome DevTools
Step 1: Select the Worker VM Context
- Open DevTools (F12 → Memory panel).
- At the top of the Memory panel, click the JavaScript VM instance dropdown (defaults to “Main”).
- Select the worker — it appears as a URL like
worker.jsorblob:…with the worker’s script path. - If the worker is not listed, confirm it is running: call
worker.postMessage(…)from the console while the Memory panel is open.
Step 2: Take Snapshot 1 (Baseline)
With the worker VM selected, click Take heap snapshot. Label this “Snapshot 1 — baseline”. This is your reference point before any work cycles.
Step 3: Trigger a Work Cycle
Send the problematic message sequence from your application. For the leak example above, send ten subscribe messages:
// DevTools console
for (let i = 0; i < 10; i++) {
worker.postMessage({ type: 'subscribe' });
}
Step 4: Take Snapshot 2 (Post-cycle)
Click Take heap snapshot again (worker VM still selected). Label “Snapshot 2 — after 10 cycles”.
Step 5: Take Snapshot 3 (Confirm leak)
Repeat Step 3, then take a third snapshot. If the same objects grow between snapshots 2 and 3, you have a confirmed leak.
Step 6: Open the Comparison View
- Click “Snapshot 3” in the left panel.
- Set the view dropdown (top-left, shows “Summary”) to Comparison.
- Set the “Compare to” dropdown to “Snapshot 2”.
- Sort by # Delta (count difference) descending.
Objects with a consistently positive # Delta between snapshots are your leak candidates.
Reading the Comparison View
The Comparison view shows four key columns:
| Column | Meaning |
|---|---|
| Constructor | Object type (Array, Function, closure, etc.) |
| # New | Objects allocated since the comparison snapshot |
| # Deleted | Objects freed since the comparison snapshot |
| # Delta | # New − # Deleted — positive = growing |
| Alloc. Size Delta | Net retained bytes |
For the listener leak above, you will see:
| Constructor | # Delta | Alloc. Size Delta |
|---|---|---|
(closure) |
+10 | +1 200 B |
Array |
+0 | +80 B (array growth) |
Expand (closure) → select an entry → check the Retaining path panel at the bottom. It will trace: (closure) ← handlers[9] ← handlers ← DedicatedWorkerGlobalScope. This chain tells you exactly which variable holds the leak.
The Three-Snapshot Technique in Practice
Two snapshots reveal whether something grew. Three snapshots prove the growth is unbounded. A one-time allocation — such as a cache that fills on first use and stays stable — looks like:
- Snapshot 1 → 2:
# Delta = +500(cache fills) - Snapshot 2 → 3:
# Delta = 0(cache stable)
A true leak looks like:
- Snapshot 1 → 2:
# Delta = +10 - Snapshot 2 → 3:
# Delta = +10
The count keeps growing proportionally to the number of cycles.
Finding Detached ArrayBuffer Leaks
Transferred buffers that are held inside the worker beyond their useful life are a common leak class. After a postMessage(..., [buffer]) from the main thread, the worker owns the buffer. If the worker stores a reference in a module-level variable and never clears it, it is a leak.
// leaky-worker-buffer.ts — DO NOT SHIP THIS
const processedBuffers: ArrayBuffer[] = [];
self.onmessage = (e: MessageEvent) => {
const buf = e.data as ArrayBuffer;
// BUG: processed buffers are accumulated, never evicted
processedBuffers.push(buf);
// Process and return
self.postMessage('ok');
};
In the Comparison view, look for ArrayBuffer with a positive # Delta. The Retaining path will show ArrayBuffer ← processedBuffers[n] ← module scope.
The fix is to process the buffer and immediately release the reference:
// fixed-worker-buffer.ts
self.onmessage = (e: MessageEvent) => {
const buf = e.data as ArrayBuffer;
processBuffer(buf);
// buf falls out of scope here — eligible for GC
self.postMessage('ok');
};
A listener leak accumulating closures at 10 per second reaches ~1 MB of retained heap after 10 minutes on a typical event-driven worker. A buffer accumulation leak in an image-processing worker handling 1 MB frames at 30 fps fills 1.8 GB in one minute. Profile early — buffer leaks are catastrophic, listener leaks are slow-burn.
Gotchas & Edge Cases
1. Worker must be paused or cooperative to get a clean snapshot
Taking a snapshot of a busy worker mid-computation produces a snapshot that includes transient allocations (live task objects, in-flight closures). These inflate # New and produce false positives. Add a message round-trip before snapshotting to ensure the worker is idle:
// Drain the worker, then snapshot
worker.postMessage({ type: 'PING' });
worker.onmessage = async (e) => {
if (e.data.type === 'PONG') {
// Worker is idle — safe to take snapshot
// (Trigger from DevTools Memory panel now)
}
};
2. The worker VM disappears if the worker terminates
If you call worker.terminate() before taking a comparison snapshot, the worker VM entry vanishes from the dropdown. Keep a reference to the worker alive during profiling. Use self.close() inside the worker instead of worker.terminate() from the main thread — the worker context remains briefly visible in DevTools after self.close() because the GC has not yet collected it.
3. WeakRef and WeakMap entries do not appear as leaks
Objects held only by WeakRef or as keys in a WeakMap are eligible for GC at any collection cycle. The snapshot may or may not capture them depending on when GC ran. Click the Collect garbage icon (trash can) in the DevTools toolbar before taking each comparison snapshot to force a GC cycle and produce cleaner diffs.
4. Module-scope variables in ESM workers are permanent for the worker lifetime
In a module worker ({ type: 'module' }), module-level variables are bound to the module namespace object, which is retained for as long as the worker is alive. A module-level const cache = new Map() that grows unboundedly will show up as a Map with a growing # Delta. The fix is to use WeakMap where keys are message-correlated objects, or to bound the cache size explicitly.
Automating Leak Detection
For CI regression testing, Chrome’s --enable-precise-memory-info flag combined with performance.measureUserAgentSpecificMemory() (where supported) can track worker heap growth programmatically:
// In main.ts, after each work cycle:
if ('measureUserAgentSpecificMemory' in performance) {
const result = await (performance as any).measureUserAgentSpecificMemory();
const workerBytes = result.breakdown
.filter((b: any) => b.attribution.some((a: any) => a.url?.includes('worker')))
.reduce((sum: number, b: any) => sum + b.bytes, 0);
console.log(`Worker heap: ${(workerBytes / 1024 / 1024).toFixed(1)} MB`);
}
measureUserAgentSpecificMemory() requires Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp — the same headers needed for SharedArrayBuffer. In environments where those headers are not set, fall back to manual DevTools snapshots.
Interpreting Snapshot Size vs Retained Size
The Comparison view shows two memory columns: Shallow size and Retained size. Understanding the difference prevents misdiagnosis.
Shallow size is the memory consumed by the object itself — its own properties, not the objects it references. A Map object with 10 000 entries has a small shallow size (~64 bytes for the Map header) but a large retained size (the sum of all its keys and values).
Retained size is the total memory that would be freed if the object — and everything reachable only through it — were collected. This is the number that matters for leak diagnosis.
When sorting by # Delta, switch to Retained size delta to rank leaks by actual memory impact rather than object count. A single Map retaining 50 000 closures is far more impactful than 50 000 small string primitives.
Comparison view columns to watch:
Constructor | # Delta | Shallow Δ | Retained Δ
(closure) | +100 | +8 KB | +4.2 MB ← high retained = real leak
String | +500 | +50 KB | +50 KB ← shallow = retained (no references)
Recognising Common Worker Leak Patterns
Pattern A: Uncleared setInterval inside a worker
// leaky: interval keeps firing and accumulating results
const results: number[] = [];
setInterval(() => {
results.push(performance.now()); // grows without bound
}, 100);
In the Comparison view: Array with growing retained size, Number with growing count. Fix: clearInterval on the interval handle when the work is complete, or limit results to a rolling window.
Pattern B: Event listeners added but not removed in repeated cycles
// leaky: each call to setupListener adds a new handler
function setupListener(port: MessagePort) {
port.onmessage = (e) => handleMessage(e); // replaces the previous handler
// BUT: if using addEventListener instead of onmessage assignment:
port.addEventListener('message', (e) => handleMessage(e)); // ACCUMULATES
}
port.addEventListener adds to a listener list; it does not replace. Each call to setupListener adds another handler. Use port.onmessage = handler for single-listener semantics, or track added listeners and call removeEventListener explicitly.
Pattern C: Stale closures over large data in promise chains
async function processChunk(bigArray: Float32Array): Promise<void> {
// bigArray is captured in the closure of every await below
await step1();
await step2();
// bigArray is still reachable here even if not needed after step1
}
V8 keeps variables alive in async function closures until the function returns. If bigArray is only needed for step1, assign the result and null out the reference:
async function processChunk(bigArray: Float32Array): Promise<void> {
const result = await step1(bigArray);
(bigArray as unknown as null); // hint: but V8 may still keep it
// Better: pass only what you need to step1 and don't capture bigArray
await step2(result);
}
For truly large buffers, the most reliable approach is to pass only a view or a summary to downstream steps rather than carrying the full buffer through the async chain.