Lesson 23 / 24
Memory Inspection
Defining a leak through reachability; shallow size, retained size, and retainer path computation, comparing snapshots, the measured difference between strong and weak references, and browser-specific leak sources.
Contents
A performance recording gives a breakdown of a moment. Some problems do not fit this window: if the measurement page slows down the longer it stays open, growing heavier on every refresh of the list, no single recording shows a long task for it. What accumulates is memory that grows over time and is never released.
This lesson covers the tool that measures that accumulation and how to find its source.
Defining a Leak: Reachability
The model established in the Memory Lifecycle lesson of the Asynchronous JavaScript and Runtime course is the basis for diagnosis here: the garbage collector reclaims every object not reachable from the root set. No reachable object is ever collected.
The definition of a leak follows from this. A memory leak is an object no longer used staying reachable anyway. It is not the garbage collector’s fault; it cannot know an object is unused, it only knows reachability. Diagnosis is therefore done not with “which object is large” but with “who still refers to this object.”
Shallow Size, Retained Size, and Retainer Path
Memory views list two separate sizes for an object, and the difference between them is the center of the diagnosis.
// retained-size.mjs — shallow size, retained size, and retainer path computation const SHALLOW = { root: 16, listener: 32, closure: 32, measurementArray: 8192, detachedRow: 128, cell1: 64, cell2: 64, dashboard: 64, badge: 48, sharedStyle: 256, }; const EDGES = { root: ["listener", "dashboard"], listener: ["closure"], closure: ["detachedRow", "measurementArray"], detachedRow: ["cell1", "cell2"], measurementArray: [], cell1: [], cell2: [], dashboard: ["badge", "sharedStyle"], badge: ["sharedStyle"], sharedStyle: [], }; const ROOT_SET = ["root"]; function reachable(excluding = null) { const seen = new Set(); const stack = ROOT_SET.filter((d) => d !== excluding); while (stack.length) { const d = stack.pop(); if (seen.has(d)) continue; seen.add(d); for (const neighbor of EDGES[d]) if (neighbor !== excluding) stack.push(neighbor); } return seen; } const size = (set) => [...set].reduce((t, d) => t + SHALLOW[d], 0); function retainedSize(node) { const before = reachable(); const after = reachable(node); return size(before) - size(after); } // Retainer path: the shortest path from the root to the node. function retainerPath(target) { const queue = ROOT_SET.map((d) => [d]); const seen = new Set(ROOT_SET); while (queue.length) { const path = queue.shift(); const last = path[path.length - 1]; if (last === target) return path; for (const neighbor of EDGES[last]) if (!seen.has(neighbor)) { seen.add(neighbor); queue.push([...path, neighbor]); } } return null; } console.log("node".padEnd(18) + "shallow".padStart(9) + "retained".padStart(10)); for (const d of Object.keys(SHALLOW)) console.log(d.padEnd(18) + String(SHALLOW[d]).padStart(9) + String(retainedSize(d)).padStart(10)); console.log("\nretainer path (detachedRow):", retainerPath("detachedRow").join(" → ")); console.log("retainer path (sharedStyle):", retainerPath("sharedStyle").join(" → "));
node shallow retained root 16 8896 listener 32 8512 closure 32 8480 measurementArray 8192 8192 detachedRow 128 256 cell1 64 64 cell2 64 64 dashboard 64 368 badge 48 48 sharedStyle 256 256 retainer path (detachedRow): root → listener → closure → detachedRow retainer path (sharedStyle): root → dashboard → sharedStyle
Shallow size is the object’s own field allocation. Retained size is the total space that would be reclaimed if that object were freed — the sum of the object and everything reachable only through it.
The listener row summarizes the distinction: its shallow size is thirty-two bytes, its retained size over eight thousand. In a view sorted by shallow size, this listener draws no attention at all; sorted by retained size, it rises to the top. In a leak hunt, the sort criterion is retained size.
The badge versus sharedStyle comparison gives the second rule. The badge’s retained
size is only its own shallow size, because the style object it points to is also
referenced from the dashboard; freeing the badge leaves that object reachable still.
Shared objects enter no retainer’s retained size; this is why summing retained sizes
does not give the heap’s total size.
Retainer path is the reference chain from the root set to the object, and it is the answer to the leak. The detached row’s path says what holds it is a closure, and what holds the closure is a listener; the fix belongs not in memory but in the code where that listener is never removed.
Measuring: Snapshot and Comparison
A single heap snapshot gives a state at one moment; a leak, though, is a trend. This is why measurement is done by comparison.
The established method rests on three snapshots. The suspected operation — opening and closing the measurement list — runs once, and the first snapshot is taken; this sets as baseline the structures that form on first use and count as normal. The operation repeats a few more times for the second snapshot, then once more for the third. Objects that keep existing between the second and third snapshot are leak candidates; ones that grew relative to the first but vanished in the second were temporary allocations.
For the comparison to be meaningful, garbage collection has to have run before each snapshot is taken; tools do this automatically while taking one. Otherwise the measured growth may just be garbage not yet collected.
Strong and Weak References
The most common leak source is helper structures that store objects as keys: a mapping holding data attached to an element keeps referring to it even after the element has left the tree. The size of the difference can be measured directly.
The following measurement calls the garbage collector by hand and, for that, must be run
with node --expose-gc; run without the flag, the call is undefined.
// leak-measurement.mjs — run with: node --expose-gc leak-measurement.mjs // Two registries do the same job; the difference is whether they hold the object // they use as a key with a strong or a weak reference. const MB = (bytes) => (bytes / 1024 / 1024).toFixed(1); function measure() { globalThis.gc(); // undefined without --expose-gc return process.memoryUsage().heapUsed; } function trial(registry, count) { const base = measure(); for (let i = 0; i < count; i++) { const node = { id: i, measurement: new Array(64).fill(i) }; registry.set(node, { lastUpdated: i }); // once the loop turn ends, nothing outside holds a reference to node } return measure() - base; } const COUNT = 200_000; console.log(`heap remaining after creating and dropping ${COUNT} nodes:`); console.log(" strong-reference registry:", MB(trial(new Map(), COUNT)), "MB"); console.log(" weak-reference registry :", MB(trial(new WeakMap(), COUNT)), "MB");
heap remaining after creating and dropping 200000 nodes: strong-reference registry: 127.5 MB weak-reference registry : 1.0 MB
The numbers depend on the runtime and machine; the ratio is what stays stable. The strong-reference registry keeps every node reachable, and none is collected. In the weak-reference registry, the key does not keep the object alive; a node with no other reference is collected, and its entry in the registry goes with it.
The rule: side data attached to an object should not extend its lifetime. A weak reference provides this, at the cost that the registry’s contents cannot be iterated — asking which keys still exist would itself keep them alive.
Browser-Specific Leak Sources
Four patterns repeat in a page’s context, and all four leave recognizable traces in the retainer path.
Detached nodes. DOM nodes removed from the tree but still referenced by a variable, an array, or a closure. They are marked as a separate class in memory views; a detached node’s entire subtree is retained with it. Holding a static list from the DOM API lesson for a long time produces this result.
Listeners not removed. Every bound listener ties both the function and its closure to the target node. The abort-signal option from the Event Model lesson solves this at the source: one cancellation removes every listener bound to that signal. For a custom element author, the counterpart is tearing down in the disconnect callback what was set up in the connect callback.
Timers and observers left running. A repeating timer keeps its callback’s closure reachable indefinitely. The same holds for an observer that is not stopped even after the element it watches leaves the tree. Both have the same remedy: an explicit stop call.
Unboundedly growing caches. A result mapping held inside a program grows for its whole lifetime if given no limit and no eviction policy. The eviction-policy concept from the Processor Cache lesson applies here: an unbounded cache is not a cache, it is a leak.
Summary
- A memory leak is an unused object staying reachable; diagnosis is done with “who still refers to this.”
- Shallow size is an object’s own field, retained size the total space reclaimed if freed; the sort criterion in a leak hunt is retained size.
- Shared objects enter no retainer’s retained size, so the sum of retained sizes does not give the heap’s size.
- Retainer path is the chain from the root set to the object and points to the line of code to fix.
- A leak is a trend; objects that keep existing across consecutive snapshots are candidates.
- Side data attached to an object should not extend its lifetime; weak-reference structures provide this, at the cost of not being iterable.
Next Step
The tools so far measured a result: which rule won, how long the request took, which function held the main thread, which object stayed in memory. None answer “what was the variable’s value when this line ran.” Why a row of the measurement list rendered missing, which branch a recording took under which condition — these are only seen by stopping the program at a chosen point and reading its state there. The next lesson covers this pausing mechanism and how paused code in production output is traced back to its source.
To keep your progress and take notes, Log in
My notes
Log in to take notes.