Identifying Memory Leaks in Workers
A Web Worker runs in its own V8 isolate with its own heap, its own garbage collector cycles, and no DOM to blame. That isolation is exactly what makes worker leaks so easy to miss: nothing in the page’s memory profile moves, the UI stays responsive, and the only visible symptom is a tab that slowly gets heavier until it is killed. This guide is a specialisation of Debugging, Profiling & Production Optimization and lays out a repeatable protocol for isolating heap growth inside background threads — from reproducing the leak deterministically, through worker-scoped snapshot diffing and retainer-chain analysis, to the cache and teardown patterns that fix it and the telemetry that stops it coming back.
The Symptom: A Worker That Gets Heavier Every Hour
The scenario that brings engineers here is specific and boring, which is why it survives code review. A dashboard spawns one long-lived worker at boot to parse and aggregate incoming telemetry rows. Each message carries about 2 MB of JSON; the worker parses it, folds it into a rolling aggregate, and posts back a summary of a few kilobytes. It is correct. It is fast. And after four hours of a trading day the tab is sitting at 1.8 GB and the parse that took 40 ms at 09:00 now takes 300 ms because major GC runs are firing constantly.
Three properties make this class of bug hard:
- The page heap looks innocent. The main thread only ever receives the small summary object. Profiling the page — the reflex from DOM-leak debugging — shows a flat line, so the worker is the last place anyone looks.
- Nothing throws. A worker heap approaching the isolate limit does not raise a catchable error; the renderer process is terminated and the
Workerobject fires anerrorevent, or the whole tab dies with “Aw, Snap”. By then all diagnostic state is gone. - Growth is invisible per-message. Retaining 4 KB per message is undetectable in one iteration and fatal over 500,000 of them. You cannot eyeball it — you have to diff.
The rest of this guide is the sequence that turns those three properties into a measurement. If the worker is not leaking but is simply slow to hand data back, the cost is on the wire rather than in the heap, and postMessage Bottleneck Analysis is the right diagnosis path instead.
Prerequisites
Before starting, confirm the following are in place:
- Chrome 90 or later for the Memory panel’s JavaScript VM instance selector, which is what lets you snapshot a worker’s isolate rather than the page’s.
- A worker you can restart on demand. A worker that runs once and calls
self.close()vanishes from the VM instance list, so gateclose()behind a flag during the investigation. - A reproducible workload — the same input, replayed a fixed number of times. Without input stability you cannot tell retention from variance.
- Workers created with
{ type: 'module' }where possible, so scope is explicit and bundler source maps line up with the frames shown in the retainer chain. - Source maps served for worker chunks. Minified closures show up as
(anonymous)in the retainer pane, which makes the last step of the protocol guesswork. - Familiarity with the Threads / VM instance context switch covered in Chrome DevTools Worker Debugging — every step below assumes you can put DevTools into the worker’s context on demand.
performance.memory is a non-standard Chromium API and its usedJSHeapSize is quantised in 100 KB buckets. Use it as a diagnostic hint during a Chrome session, not as ground truth. It does not exist in Firefox or Safari. For production-grade alerting, pair it with your own object-count counters so the signal survives on every engine.
Worker Memory Lifecycle: Why the Main-Thread Playbook Fails
Each WorkerGlobalScope owns a separate heap. Objects sent over postMessage are structured-cloned into that heap unless they appear in a transfer list, which means the worker’s copy has an entirely independent lifetime from the main thread’s original. Two consequences follow, and both invert habits learned from DOM leak hunting.
First, there is no DOM to detach. Classic page leaks are dominated by detached DOM subtrees held by a stale reference. A worker has none of that vocabulary; its leaks are plain JavaScript retention — a module-scoped array that only ever gets pushed to, a Map keyed by request id whose entries are never deleted, a listener added on every message, a setInterval closing over the last payload.
Second, the only guaranteed collection point is termination. Calling worker.terminate() from the main thread or self.close() from inside destroys the isolate and reclaims everything in one step. That is a real tool — a worker in a pool that is recycled every N tasks cannot leak unboundedly — but it is a blunt one, and it is not available to a worker that must hold warm state. The lifecycle discipline in Handling Worker Termination Gracefully in SPAs is the safe version of this: drain in-flight work, then tear down.
// main.ts — explicit lifecycle management with typed messages
type WorkerOut =
| { type: 'PROCESSING_COMPLETE'; metrics: { used: number; baseline: number } }
| { type: 'MEMORY_ALERT'; growthMb: number };
const worker = new Worker(new URL('./worker-processor.ts', import.meta.url), { type: 'module' });
worker.addEventListener('message', (e: MessageEvent<WorkerOut>) => {
if (e.data.type === 'PROCESSING_COMPLETE') {
console.log('[main] task finished, worker heap metrics:', e.data.metrics);
// terminate() destroys the isolate: every byte the worker held is reclaimed,
// including anything a leak was retaining. Only safe once work has drained.
worker.terminate();
}
});
worker.postMessage({ action: 'EXECUTE_PIPELINE', payload: heavyDataset });
worker.terminate() — destroying the isolate — is the one operation guaranteed to reclaim it.Step-by-Step: Isolating the Leak
The six steps below are the working protocol: reproduce, baseline, snapshot, diff, retain, fix. Each imposes a cost — usually timing fidelity — in exchange for visibility, so each ends with its trade-off.
Step 1 — Drive the worker with a repeatable workload
A leak measurement is a subtraction, and subtraction only means something when everything except retention is held constant. Replay one identical payload a fixed number of times, waiting for an explicit acknowledgement between iterations so you know the worker is idle when you snapshot it.
// leak-harness.ts — replay one identical payload N times with a quiescent gap between runs
const MAX_ITERATIONS = 3;
export async function replay(worker: Worker, input: ArrayBuffer, iterations = MAX_ITERATIONS) {
for (let i = 0; i < iterations; i++) {
// Send a fresh copy each round: transferring would detach the source buffer
// after the first iteration and silently change the workload.
const copy = input.slice(0);
await new Promise<void>((resolve) => {
worker.addEventListener(
'message',
(e: MessageEvent<{ status: string }>) => {
if (e.data.status === 'CLEANUP_COMPLETE') resolve();
},
{ once: true },
);
worker.postMessage({ action: 'RUN_BATCH', payload: copy }, [copy]);
});
// Snapshot here, between iterations — never mid-run.
console.log(`[harness] iteration ${i + 1}/${iterations} complete`);
}
}
Waiting for an acknowledgement between iterations removes concurrency, which is exactly what makes the measurement readable — but it also hides leaks that only appear under overlapping messages, such as a pending-request Map whose entries are deleted in a response handler that never runs for cancelled work. Once the serialised leak is fixed, replay again with the messages pipelined.
Step 2 — Record a heap baseline inside the worker
DevTools tells you the heap size at snapshot time. What you want in addition is a cheap continuous signal from inside the isolate, so you can see the shape of growth without pausing anything.
// worker-processor.js — sample the worker's own heap around each unit of work
self.onmessage = (e) => {
if (e.data.action !== 'RUN_BATCH') return;
// performance.memory is Chromium-only and quantised; ?? 0 keeps other engines running.
const baseline = performance.memory?.usedJSHeapSize ?? 0;
const result = processBatch(e.data.payload);
const used = performance.memory?.usedJSHeapSize ?? 0;
self.postMessage({
status: 'CLEANUP_COMPLETE',
metrics: {
baseline,
used,
retainedKb: Math.round((used - baseline) / 1024), // per-iteration delta
rows: result.rowCount,
},
});
};
A single delta mixes real retention with allocations that simply have not been collected yet — a young-generation scavenge may not have run at all. Treat one reading as meaningless and the trend across iterations as the signal: a leak produces a floor that rises monotonically, while healthy churn produces a sawtooth that returns to the same floor.
Step 3 — Snapshot the worker’s heap, not the page’s
This is the step most often done wrong, and doing it wrong produces a clean bill of health for a leaking worker.
- Open DevTools → Memory.
- In the profile-type panel, find the JavaScript VM instance list (labelled Threads in older builds) and select the entry whose URL matches your worker script — not the top-level document.
- Click Collect garbage (the bin icon) so the snapshot reflects reachable objects only.
- Choose Heap snapshot and click Take snapshot. Chrome forces a full GC before writing the snapshot, so what you get is the retained set.
- Run one workload iteration, then repeat from step 3. Take at least three snapshots.
Taking a heap snapshot pauses the isolate for roughly 50–200 ms on a small heap, and multiple seconds on a heap in the hundreds of megabytes. Any performance.now() measurement spanning a snapshot is worthless. Snapshot between iterations, never during active processing, and record timing in a separate run with the Memory panel closed.
Step 4 — Diff the snapshots and rank by retained size
Switch the snapshot view from Summary to Comparison and pick the earlier snapshot as the base. The # Delta column is the object-count change; Size Delta is the byte change. Sort by Size Delta descending and read the constructor names.
Interpretation rules that save time:
- Shallow size is the object’s own memory. Retained size is everything that would be freed if the object went away. Chase retained size — a 32-byte closure retaining a 40 MB typed array is the leak, and it will never look big by shallow size.
- Constructors that appear in every diff with a positive delta are candidates. Constructors that grow once and then plateau are caches warming up, not leaks.
(closure),(array),(system)and(compiled code)are synthetic groupings, not your classes. A growing(closure)count usually means listeners or timers accumulating.ArrayBuffergrowth in a worker that receives transferred buffers points at the detached-view problem covered below.
The full mechanics of the Comparison view — including the three-snapshot technique for filtering out objects allocated by the tooling itself — are covered in Heap Snapshot Diffing for Worker Leaks.
Sorting by Size Delta finds buffer and string leaks immediately, but a leak of a million 40-byte objects ranks below a single warm cache. When the size view looks clean and the heap still grows, re-sort by # Delta and look for a constructor whose count rises by exactly the number of messages you sent.
Step 5 — Walk the retainer chain to the owning reference
Select a leaked object and read the Retainers pane at the bottom of the panel. It answers the only question that matters: what is still pointing at this? Walk it upward until you hit a GC root. In a worker there are four realistic roots:
- The module scope — a top-level
const rows = []that is only ever pushed to. - A closure — a callback captured in a listener list, a promise chain, or a memo table.
- The event-listener list on
self—self.addEventListener('message', …)called per request instead of once. - A timer —
setIntervalor a chain ofsetTimeoutcallbacks capturing the last payload.
Whichever root you land on is the leak. Everything below it in the chain is a symptom.
Without source maps the retainer chain reads (closure) in n and the investigation stalls. Reproduce leaks against a development build, or ship worker source maps to your error backend as described in Production Error Telemetry for Web Workers. The cost is that a development build allocates differently from production, so confirm the fix against a production build afterwards.
Step 6 — Fix the retention: bound it, tear it down, or weaken it
Fixes fall into three families, in order of preference.
Bound it. Any structure that grows with traffic needs a ceiling — a ring buffer, an LRU with a max size, or a queue with backpressure. This is the fix for accumulating results, request maps and log arrays, and it is the only one with deterministic memory behaviour.
Tear it down. Register listeners once at module scope, not per message. Pair every setInterval with a clearInterval on a stop message. Null out references to large payloads as soon as the reply is posted.
Weaken it. For a cache whose values are recomputable and whose keys outlive them, WeakRef plus FinalizationRegistry lets the collector reclaim entries while keeping the lookup table tidy.
// cache-worker.js — a recomputable cache that does not pin its values
const weakCache = new Map(); // key -> WeakRef<value>
// The registry callback runs after the value is collected, removing the
// now-empty WeakRef so the Map itself does not grow without bound.
const registry = new FinalizationRegistry((key) => {
weakCache.delete(key);
});
function cacheSet(key, value) {
weakCache.set(key, new WeakRef(value));
registry.register(value, key);
}
function cacheGet(key) {
const value = weakCache.get(key)?.deref();
if (value === undefined) weakCache.delete(key); // collected: drop the stale slot
return value; // callers MUST handle undefined
}
export function getOrCompute(key, compute) {
const hit = cacheGet(key);
if (hit !== undefined) return hit;
const fresh = compute(key);
cacheSet(key, fresh);
return fresh;
}
Collection timing is entirely up to the engine — a value may survive long after it is unreachable, or vanish immediately after you read it. FinalizationRegistry callbacks are not guaranteed to run at all, and never run at worker shutdown. Never use WeakRef for correctness, for resources that need explicit release, or in a latency-sensitive hot path where a silent miss turns into a recompute spike. When you need a predictable ceiling, a bounded LRU Map is the better tool.
Clone, Transfer or Share: How the Data Path Creates Leaks
The way payloads cross the thread boundary changes both what can leak and where the leaked bytes show up. All three strategies have a signature failure.
Structured clone (the default). worker.postMessage(bigObject) deep-copies into the worker’s heap. The copy is a normal object with a normal lifetime, so it leaks the way any object leaks: something in the worker keeps a reference. The tell is a heap that grows in units of the payload size. Clone also has a cost that is easily mistaken for a leak — a large graph serialised on every message inflates peak heap without retaining anything; Measuring Structured Clone Cost with performance.now() separates the two.
Transfer (zero-copy). Passing an ArrayBuffer in the transfer list moves ownership instead of copying it, as described in Transferable Objects & Zero-Copy. The sender’s buffer is detached and its byteLength becomes 0. The signature leak here is subtle: the bytes have moved, but any TypedArray view created over the old buffer is still a live JavaScript object, and a closure holding a stack of such views keeps a growing pile of detached wrappers alive. In the Comparison view this shows up as rising Uint8Array / Float64Array counts with almost no size delta — small objects, large count.
SharedArrayBuffer. Shared memory is never collected while either thread holds a reference, which turns a leak on one side into a leak on both. A ring buffer sized at construction cannot grow, so the classic unbounded-growth leak disappears; what replaces it is a retention leak, where a worker that has finished with a shared region keeps its reference and the allocation survives every subsequent GC. The coordination protocols in SharedArrayBuffer & Atomics should include an explicit release step for exactly this reason.
// main.ts — transfer ownership, then stop referencing the source
const buffer = new ArrayBuffer(50 * 1024 * 1024); // 50 MB
const view = new Uint8Array(buffer); // view over the same bytes
worker.postMessage({ data: buffer }, [buffer]); // ownership moves to the worker
console.log(buffer.byteLength); // → 0: detached, reading through `view` yields nothing
// `view` is now a 50 MB-shaped object holding 0 bytes of data but still a live
// JS object. Drop it, or an array of these accumulates one wrapper per message.
SharedArrayBuffer is only defined on a cross-origin isolated document: the response must send Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp, and every cross-origin subresource must opt in with CORP or CORS. Verify with self.crossOriginIsolated === true inside the worker. The same headers gate performance.measureUserAgentSpecificMemory(), so an isolated page gets both shared memory and the accurate memory API together.
Verification & Measurement
A fix is only proven when the same workload produces a flat retained heap. Three checks, in increasing order of confidence:
- Snapshot parity. Replay the workload and diff snapshot 1 against snapshot 3. The constructor you fixed must show a delta of zero or near zero. A residual delta of a few objects is normal — DevTools itself allocates into the isolate.
- Soak test. Run one to two orders of magnitude more iterations than the reproduction needed and watch the sampled heap floor. A fixed leak produces a sawtooth around a stable floor; a partially fixed leak produces a sawtooth around a rising floor, which is the same shape at a slower rate.
- Accurate byte accounting. On a cross-origin isolated page,
performance.measureUserAgentSpecificMemory()reports real allocated bytes broken down by type and is available inside workers from Chrome 89. It is asynchronous and deliberately slow — the browser schedules it with the next GC — so call it every few seconds at most.
// soak-worker.js — measure the heap floor, not the peak
const floors = [];
async function sampleHeapFloor() {
// measureUserAgentSpecificMemory resolves after a GC, so the value it
// reports is a post-collection floor rather than a mid-churn peak.
if (typeof performance.measureUserAgentSpecificMemory === 'function') {
const { bytes } = await performance.measureUserAgentSpecificMemory();
return bytes;
}
return performance.memory?.usedJSHeapSize ?? 0;
}
export async function soak(runBatch, input, iterations = 500) {
for (let i = 0; i < iterations; i++) {
runBatch(input);
if (i % 25 === 0) floors.push({ i, bytes: await sampleHeapFloor() });
}
// Least-squares slope over the floors: bytes retained per iteration.
const n = floors.length;
const meanI = floors.reduce((s, f) => s + f.i, 0) / n;
const meanB = floors.reduce((s, f) => s + f.bytes, 0) / n;
const slope =
floors.reduce((s, f) => s + (f.i - meanI) * (f.bytes - meanB), 0) /
floors.reduce((s, f) => s + (f.i - meanI) ** 2, 0);
return { bytesPerIteration: Math.round(slope), samples: floors };
}
A slope under roughly 1 KB per iteration on a workload that allocates megabytes is noise. A slope that matches a recognisable fraction of your payload size — a quarter of it, all of it — is a leak with a name, and the snapshot diff will tell you which constructor it belongs to. In a pooled setup, compare the slope per worker: a single hot worker leaking while its siblings are flat usually means work is not being distributed evenly, which is a Worker Pool Management scheduling problem rather than a retention bug.
Failure Modes & Error Handling
Out-of-memory termination. A worker that exhausts its isolate is killed without a catchable exception. The main thread sees an error event on the Worker object, and the ErrorEvent is frequently empty. Treat a silent, message-less error on a long-lived worker as an OOM until proven otherwise, and record the last known heap sample alongside it.
// supervisor.ts — restart a worker that dies, and carry the last heap sample with it
let lastHeapSample = 0;
let restarts = 0;
function attach(worker: Worker): Worker {
worker.addEventListener('message', (e: MessageEvent<{ type?: string; used?: number }>) => {
if (e.data.type === 'MEMORY_METRIC' && typeof e.data.used === 'number') {
lastHeapSample = e.data.used;
}
});
worker.addEventListener('error', (e: ErrorEvent) => {
// An empty message on a long-lived worker is the OOM fingerprint.
report({
kind: e.message ? 'worker-error' : 'worker-oom-suspected',
message: e.message || '(none)',
lastHeapMb: +(lastHeapSample / 1048576).toFixed(1),
restarts,
});
if (restarts++ < 3) {
worker.terminate();
attach(new Worker(new URL('./worker-processor.ts', import.meta.url), { type: 'module' }));
}
});
return worker;
}
Restart is mitigation, not a fix. Recycling a worker after N tasks or M megabytes caps the damage and is a legitimate production safety net — but it converts a leak into periodic cold starts, each paying worker boot plus any warm-cache rebuild. The restart policy and backoff belong with the rest of your recovery logic in Error Handling & Crash Recovery.
Watchdog false positives. A growth-ratio alarm fires on legitimate warm-up: a worker that lazily builds a 200 MB index on first use trips any ratio threshold. Start the sliding window only after the first N messages, and compare against a floor sampled post-GC rather than an instantaneous reading.
// telemetry-worker.js — sliding-window leak watchdog
const SAMPLE_INTERVAL_MS = 5000;
const WINDOW = 6; // 6 samples ≈ 30 s
const LEAK_THRESHOLD_RATIO = 1.15; // 15% growth across the window
const WARMUP_MESSAGES = 20;
const samples = [];
let messagesSeen = 0;
const intervalId = setInterval(() => {
if (messagesSeen < WARMUP_MESSAGES) return; // ignore index build-up
const used = performance.memory?.usedJSHeapSize ?? 0;
samples.push(used);
if (samples.length > WINDOW) samples.shift();
if (samples.length === WINDOW && samples[WINDOW - 1] / samples[0] > LEAK_THRESHOLD_RATIO) {
self.postMessage({
type: 'MEMORY_ALERT',
growthMb: +((samples[WINDOW - 1] - samples[0]) / 1048576).toFixed(1),
});
samples.length = 0; // re-arm rather than alerting every tick
}
}, SAMPLE_INTERVAL_MS);
self.addEventListener('message', (e) => {
messagesSeen++;
if (e.data.action === 'STOP_TELEMETRY') {
clearInterval(intervalId); // a watchdog that outlives its worker is itself a leak
self.close();
}
});
The watchdog must not become the leak. An uncleared setInterval holding a sample array is precisely the pattern this guide exists to find. Clear it on shutdown, cap the sample array, and never let it retain payloads. Routing the resulting MEMORY_ALERT events into your error backend — with release tag, worker URL and uptime attached — is covered in Production Error Telemetry for Web Workers.
Browser Compatibility
| Feature | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
| Worker VM instance in Memory panel | 90+ | Not available | Not available | 90+ |
performance.memory in workers |
43+ | Not available | Not available | 79+ |
performance.measureUserAgentSpecificMemory() |
89+ (isolated only) | Not available | Not available | 89+ |
WeakRef in workers |
84+ | 79+ | 14.1+ | 84+ |
FinalizationRegistry in workers |
84+ | 79+ | 14.1+ | 84+ |
Transferable ArrayBuffer |
17+ | 18+ | 6+ | 12+ |
SharedArrayBuffer (COOP/COEP) |
68+ | 79+ | 15.2+ | 79+ |
Module workers ({ type: 'module' }) |
80+ | 114+ | 15+ | 80+ |
Only Chromium exposes worker-scoped heap snapshots, so leak isolation is a Chrome workflow even when the bug reproduces everywhere. Firefox’s about:memory reports per-compartment totals and will confirm that a worker is growing, which is enough to reproduce, but it does not give you a retainer chain — the practical split between the two toolchains is laid out in Comparing Chrome and Firefox Worker Tooling.
The Leak Isolation Cycle at a Glance
The whole protocol compresses to four moves you repeat until the delta is zero: force a collection so you are looking at the retained set, take a baseline snapshot in the worker’s own context, run an identical workload, then snapshot again and read the Comparison view sorted by retained size. Constructors that grow on every pass are the leak; the retainer chain names the reference that owns it, and the fix is to bound it, tear it down, or weaken it.
Once the diff is flat, keep the watchdog running. Leaks are regressions like any other, and the cheapest place to catch the next one is the sliding window you already shipped.