---
title: 'Diagnosing Memory Leaks'
source: 'https://academia.sh/en/courses/asynchronous-javascript/diagnosing-memory-leaks'
course: 'Asynchronous JavaScript and the Runtime'
language: en
updated: '2026-08-17T18:09:44+00:00'
license: 'CC BY-SA 4.0'
---

# Diagnosing Memory Leaks

A leak defined as unwanted reachability, four common sources in asynchronous code, what a heap snapshot contains, and the method of counting accumulation by comparing two snapshots.

The collector collects every unreachable object. So why does memory fill up? The answer
is in the definition itself: objects that do not get collected are **reachable** ones. A
leak is not memory the collector missed, it is memory the program is holding without
realizing it.

This lesson makes a leak measurable. First the four most common sources in asynchronous
code are recognized, then accumulation is counted with a heap snapshot. The measurement
stream's leaking and bounded versions will be compared with the same criterion.

## The Definition of a Leak

A **memory leak** is a no-longer-needed object staying reachable from the roots. The
definition has two parts and both are required: the object is unnecessary *and* it is
reachable.

This definition reduces leak hunting to a concrete question: **through which reference
chain is this object reached?** Once the chain is found, the fix is clear too; the line
that breaks the chain is written.

One warning is needed: growing memory is not always a leak. Memory grows while a cache
fills up, and that is by design. The distinguishing criterion is **whether the growth
stops**. A bounded structure levels off at some point; a leak keeps growing linearly.

## Four Sources in Asynchronous Code

**Unbounded accumulation.** Code that appends every result to an array or map and never
removes anything. This is the most common form in the measurement stream: it starts with
"keep the history around," and because no limit is set, it continues.

**A listener that is not removed.** A listener added to an event source and never removed
holds everything it closes over for as long as the source lives. If the same component
registers repeatedly, the listener count also grows linearly; runtimes warn past a
certain count.

**An uncanceled timer.** A pending timer is a root; every value its callback closes over
stays in memory. A self-scheduling stream keeps this up indefinitely unless it is
stopped.

**An unsettled promise.** A promise that stays pending holds the callbacks bound to it
and the values those callbacks close over.

The last source is worth showing because it is the least visible.

```js
const registry = new FinalizationRegistry((label) => {
  console.log("collected:", label);
});

function pendingWork() {
  const buffer = { name: "measurement buffer" };
  registry.register(buffer, "buffer held by the unsettled promise");
  const promise = new Promise(() => {
    // This promise never settles.
  });
  promise.then(() => console.log("handled:", buffer.name));
  return promise;
}

let pending = pendingWork();

global.gc();
await new Promise((resolve) => setTimeout(resolve, 10));
global.gc();
console.log("collection round finished while reference to the promise persists");

pending = null;
global.gc();
await new Promise((resolve) => setTimeout(resolve, 10));
global.gc();
await new Promise((resolve) => setTimeout(resolve, 10));
console.log("round after the reference was dropped finished");
```

```
$ node --expose-gc pending.mjs
collection round finished while reference to the promise persists
collected: buffer held by the unsettled promise
round after the reference was dropped finished
```

The reference chain is: module variable → pending promise → registered reaction → the
callback's closure → buffer. Once the chain's first link was dropped, the whole thing was
collected.

The practical rule that follows re-justifies the Cancellation and Timeouts lesson from
the memory side: every wait needs a guarantee of ending. A timeout is not just a
user-experience decision, it is a memory decision.

## Heap Snapshot

A **heap snapshot** is a record of the object graph at a specific moment: nodes are
objects, edges are references. Type, name, and size information is kept for every node.

Two magnitudes are read separately in a snapshot. **Shallow size** is the room the object
occupies by itself. **Retained size** is the total room that would be recovered if the
object were collected — that is, the sum of everything reachable only through that
object. The one that matters in leak hunting is the second: a small object can be holding
up a large structure.

The snapshot is taken from the runtime's debugging interface. It can also be taken from
inside the program; the function below does this and counts how many instances of a
given class are alive.

## Comparing Two Snapshots

A single snapshot says "there are a lot of objects"; it does not show a leak. The method
is to repeat the same job and compare snapshots:

1. A snapshot is taken before the operation starts — the baseline.
2. The operation is repeated many times.
3. A second snapshot is taken and the difference is examined.

The expectation is that the operation's temporary objects will not be found in the second
snapshot. If they are found, and their count is proportional to the repeat count, there
is accumulation.

```js
import { Session } from "node:inspector/promises";

class Measurement {
  constructor(station, value) {
    this.station = station;
    this.value = value;
  }
}

async function instanceCount(className) {
  const session = new Session();
  session.connect();
  const chunks = [];
  session.on("HeapProfiler.addHeapSnapshotChunk", (message) => chunks.push(message.params.chunk));
  await session.post("HeapProfiler.takeHeapSnapshot", { reportProgress: false });
  session.disconnect();

  const snapshot = JSON.parse(chunks.join(""));
  const fields = snapshot.snapshot.meta.node_fields;
  const typeIndex = fields.indexOf("type");
  const nameIndex = fields.indexOf("name");
  const objectType = snapshot.snapshot.meta.node_types[typeIndex].indexOf("object");

  let count = 0;
  for (let i = 0; i < snapshot.nodes.length; i += fields.length) {
    const type = snapshot.nodes[i + typeIndex];
    const name = snapshot.strings[snapshot.nodes[i + nameIndex]];
    if (type === objectType && name === className) count += 1;
  }
  return count;
}

const history = [];
function leakyHandle(measurement) {
  history.push(measurement);
}

const window = [];
function boundedHandle(measurement) {
  window.push(measurement);
  if (window.length > 10) window.shift();
}

console.log("snapshot 1 — Measurement instances:", await instanceCount("Measurement"));

for (let i = 0; i < 500; i += 1) leakyHandle(new Measurement("A1", 21.4));
console.log("snapshot 2 — after the leaky handler:", await instanceCount("Measurement"));

history.length = 0;
for (let i = 0; i < 500; i += 1) boundedHandle(new Measurement("A1", 21.4));
console.log("snapshot 3 — after the bounded handler:", await instanceCount("Measurement"));
```

```
snapshot 1 — Measurement instances: 0
snapshot 2 — after the leaky handler: 500
snapshot 3 — after the bounded handler: 10
```

The numbers give the diagnosis directly. Five hundred measurements were handled; after
the leaky handler, all five hundred of the five hundred are in memory. In the bounded
handler, only as many objects as the window size — ten — remain, the rest were collected.

The strength of the criterion is in the ratio itself: if instance count grows
proportionally with operation count, the leak is certain. If it sits at a fixed upper
bound, the structure is bounded.

Taking a snapshot itself triggers a garbage-collection round; this is why only truly
reachable objects show up in the count. This is why the count comes out stable.

## From Diagnosis to Fix

Counting tells you which class is accumulating; it does not tell you the reference
chain. In interface-based tools, the chain is read directly through the "retainers"
view. In a count done from inside the program, the chain is inferred from the code
itself: which structure is holding the object, why that structure is growing.

The fix reduces to one of three patterns. Setting a limit — a window, a bounded cache, a
policy that drops the oldest entry. Removing the link — removing the listener,
canceling the timer, stopping the request. Switching to a weak reference — if a
mapping's lifetime should depend on the key object's lifetime.

## Summary

- A leak is a no-longer-needed object staying reachable from the roots; it is a program
  defect, not a collector defect.
- There are four common sources in asynchronous code: unbounded accumulation, a listener
  that is not removed, an uncanceled timer, and an unsettled promise.
- A heap snapshot is a record of the object graph; in leak hunting, retained size is more
  informative than shallow size.
- Diagnosis is not made with a single snapshot but by comparing two snapshots taken
  before and after repeating the same operation.
- If instance count grows proportionally with repeat count, there is a leak; if it sits
  at a fixed bound, the structure is bounded.

## Next Step

The memory side is complete. What remains is the time side: a program can be slow without
leaking memory at all. The next lesson reads the main thread's timeline — the definition
of a long task, what event loop lag means, what a sampling profiler records, and the
observable effect of splitting a long job and yielding turns in between.
