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:

  1. 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.
  2. Nothing throws. A worker heap approaching the isolate limit does not raise a catchable error; the renderer process is terminated and the Worker object fires an error event, or the whole tab dies with “Aw, Snap”. By then all diagnostic state is gone.
  3. 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.

The same four hours, measured on two heaps Two stacked charts share one four-hour trading-day timeline. The upper chart shows the main-thread heap oscillating flat around 60 megabytes because the page only ever receives a small summary object. The lower chart shows the worker heap as a sawtooth whose floor rises on every garbage-collection cycle, climbing from 80 megabytes at 09:00 to 1.8 gigabytes, with a marker just after 12:00 where per-message parse time has degraded from 40 milliseconds to 300 milliseconds. Main-thread heap — what the page profile shows flat all session: only the 4 KB summary ever arrives here 60 MB Worker heap — same four hours parse 40 ms → 300 ms ~4 KB retained per message · one message every few seconds every GC stops short of the previous floor 1.8 GB 80 MB 09:00 10:00 11:00 12:00 13:00
The dashed line through the worker chart is the floor — the heap left standing after each collection. A leak is not the sawtooth; it is the floor rising underneath it, which the page's own profile can never show.

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 gate close() 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 Chromium-only

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 });
Two isolates, two sets of GC roots, one message channel The main-thread isolate and the worker isolate are drawn as separate bounded heaps. Each holds its own GC roots: window, document and listeners on the page side; module scope, self and timers on the worker side. Two arrows cross the gap — postMessage with a structured clone, which gives the worker its own copy, and postMessage with a transfer list, which moves ownership of an ArrayBuffer and detaches the sender's copy. A module-scoped array growing inside the worker is anchored only to the worker's roots, so it is unreachable from, and invisible to, the page's heap profile. Main-thread isolate Worker isolate — its own heap and GC GC roots window · document · listener lists summary object · 4 KB collected after the next render Uint8Array view detached — 0 bytes, still a live object profile here: flat, ~60 MB GC roots module scope · self · timers const rows = [] — the leak pushed on every message, never trimmed ArrayBuffer · 50 MB owned here after the transfer profile here: 80 MB → 1.8 GB postMessage(obj) structured clone: a copy postMessage(buf, [buf]) ownership moves Neither set of roots can see the other heap — which is why the page's memory profile clears a leaking worker.
The message channel is the only edge between the two graphs. Everything the worker retains hangs off the worker's own roots, so 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`);
  }
}
Trade-off: serialised replay is not your production traffic

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,
    },
  });
};
Trade-off: a per-iteration delta is noisy on purpose

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.

  1. Open DevTools → Memory.
  2. 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.
  3. Click Collect garbage (the bin icon) so the snapshot reflects reachable objects only.
  4. Choose Heap snapshot and click Take snapshot. Chrome forces a full GC before writing the snapshot, so what you get is the retained set.
  5. Run one workload iteration, then repeat from step 3. Take at least three snapshots.
Trade-off: snapshots stop the world

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.
  • ArrayBuffer growth 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.

Trade-off: filtering by size hides small-object 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 selfself.addEventListener('message', …) called per request instead of once.
  • A timersetInterval or a chain of setTimeout callbacks capturing the last payload.

Whichever root you land on is the leak. Everything below it in the chain is a symptom.

Walking the Retainers pane from a leaked object to its GC root On the left, a four-link retainer chain read from the bottom up: the selected object, a forty-megabyte Uint8Array, is held in an array slot, which is held by a closure created inside handleBatch, which is anchored to the module scope of the worker script — the GC root, and the leak. On the right, the four roots a worker chain realistically terminates at: module scope, a closure, the event-listener list on self, and a timer. Retainers pane, read upward Where the chain is allowed to end GC root — module scope of parser.js stop here: this reference is the leak (closure) in handleBatch captures the batch it was created for elements[17] in Array an ordinary slot — a symptom, not a cause Uint8Array · 40 MB retained the row you selected in the Comparison view Start at the bottom, in the diff. Stop at the first root — everything below it is a consequence. 1 · Module scope a top-level const rows = [] that is only ever pushed to 2 · Closure captured by a listener list, a promise chain or a memo table 3 · Listener list on self self.addEventListener('message', …) called once per request 4 · Timer setInterval, or a setTimeout chain, holding the last payload
The chain has one useful endpoint. Everything between the leaked object and the root is bookkeeping — array slots, internal maps, wrapper objects — and rewriting any of it changes nothing until the root reference itself is bounded, cleared or weakened.
Trade-off: minified builds break the last step

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;
}
Trade-off: WeakRef buys memory with non-determinism

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 requires cross-origin isolation

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.

Three data paths, three different leaks A four-row comparison of the three ways a payload crosses the thread boundary. Structured clone sends a deep copy with independent lifetimes; its leak is the worker retaining that copy, and the heap grows in payload-sized steps. Transfer moves ownership of an ArrayBuffer and detaches the sender; its leak is a pile of detached typed-array views, which appears in a diff as a rising object count with almost no size delta. SharedArrayBuffer maps one allocation into both threads; its leak is a finished region neither side releases, which appears as a flat heap holding one region that survives every collection. How the data path decides what can leak Structured clone postMessage(obj) Transfer postMessage(buf, [buf]) SharedArrayBuffer needs COOP + COEP What crosses the boundary a deep copy of the graph serialised, then rebuilt ownership of the buffer zero bytes are copied nothing — both threads map the same allocation Who owns the bytes after two copies, one per heap independent lifetimes the receiver, exclusively sender byteLength → 0 both, jointly — freed only when both refs drop Signature leak the worker keeps the copy results.push(msg.data) detached views pile up wrappers held in a closure a finished region is never released by either side Snapshot fingerprint heap climbs in steps one payload per message count up, size delta ≈ 0 Uint8Array × N, tiny each flat heap, one region that survives every GC
Read the bottom row first when you are triaging: the shape of the diff tells you which data path the leak lives on, and therefore which of the three fixes — bound the retained copy, drop the stale views, or release the shared region — is the one that will move the number.

Verification & Measurement

A fix is only proven when the same workload produces a flat retained heap. Three checks, in increasing order of confidence:

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

Soak test: the slope of the heap floor, before and after the fix Two series of post-collection heap-floor samples taken every twenty-five iterations across a five-hundred-iteration soak. The leaking build starts near 120 megabytes and rises to about 355, and its least-squares fit line has a slope of roughly 480 kilobytes per iteration. The fixed build scatters within a few megabytes of 120 for the whole run and its fit line is flat, at roughly 0.4 kilobytes per iteration, which is indistinguishable from measurement noise. Heap floor after GC, sampled every 25 iterations (MB) leaking build after the fix 100 200 300 400 slope ≈ 480 KB / iteration slope ≈ 0.4 KB / iteration — noise, not retention 0 100 200 300 400 500 iterations of the identical workload — a slope worth a recognisable fraction of the payload size is a leak with a name
Fit a line to the floors, not to the peaks: the peaks move with allocation rate and tell you nothing. A slope in kilobytes per iteration converts directly into hours-to-OOM at your production message rate.

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();
  }
});
What the sliding-window watchdog sees A bar per five-second heap sample. The first four samples climb steeply while a lazily built index is allocated; these fall inside the warm-up region and are discarded because fewer than twenty messages have been seen. The next five samples sit at a stable floor. From there the floor rises steadily, and once the sixth sample of the sliding window exceeds the first by more than the 1.15 ratio the worker posts a MEMORY_ALERT and empties the sample array to re-arm rather than alerting on every tick. usedJSHeapSize, sampled every 5 s 6-sample window ≈ 30 s warm-up first 20 messages steady state: the floor holds 0 s 30 s 60 s time → Ignored while messagesSeen < 20 a lazily built 200 MB index trips any ratio threshold MEMORY_ALERT — 1.19× over 30 s then samples.length = 0 to re-arm the window
The threshold is a ratio across a window, not an absolute ceiling — that is what keeps a legitimate 200 MB index build from paging someone at 03:00, while still catching a floor that creeps by 15% every half minute.

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.

Heap snapshot diffing workflow for worker leak isolation Three-snapshot cycle: baseline after GC, workload run, second snapshot, Comparison view to filter growing constructors, then apply WeakRef or explicit cleanup. Force GC Memory › Collect garbage icon Snapshot A select the worker's JavaScript VM instance Run Workload identical inputs × 3 iterations Snapshot B Comparison view sort Retained Size Growing constructors → apply WeakRef / explicit cleanup ArrayBuffer · Closure · Map · Promise are most common culprits
The four-step heap diffing cycle: force GC, take baseline Snapshot A in the worker context, run identical workloads, take Snapshot B and use Comparison view to find growing constructors.

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.

Frequently Asked Questions

How do I take a heap snapshot for a specific Web Worker in Chrome?
In Chrome DevTools, open the Memory panel and look for the JavaScript VM instance list (older builds label it a Threads dropdown) at the top of the panel. Select your worker there before clicking ‘Take snapshot’. Without switching context you will snapshot the main-thread heap, not the worker’s — and a worker leak is invisible from the page’s isolate.
What are the most common sources of memory leaks in long-running workers?
Unbounded array growth in processing queues, module-level or closure-scoped references that survive message cycles, addEventListener handlers registered per message and never removed, and uncleaned setInterval / setTimeout callbacks. Also watch for ArrayBuffer views retained after ownership transfer — the buffer is detached but a TypedArray view of it may still live in a closure, keeping the wrapper object alive.
When should I use WeakRef instead of a plain Map for worker caches?
Use WeakRef when the cache value is an object whose lifecycle you do not control and whose loss is acceptable — a recomputable result, not a required resource. Always check ref.deref() for undefined and have a regeneration path. Never rely on WeakRef for cache correctness in latency-sensitive hot paths; a bounded LRU Map gives you deterministic eviction and predictable timing.
How can I detect a memory leak automatically in a production worker?
Sample performance.memory.usedJSHeapSize at a fixed interval (Chromium only) or performance.measureUserAgentSpecificMemory() on cross-origin isolated pages, and emit an alert when the ratio of the latest sample to the baseline exceeds a threshold — 1.15 (15% growth) over a 30-second window is a practical starting point. Supplement with your own object-count counters for cross-browser coverage, since neither memory API exists in Firefox or Safari.

See also