Skip to content
academia.sh

Lesson 15 / 17

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.

Contents

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.

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.

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.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close