A managed runtime can reclaim an object only when no live root can reach it through references the collector treats as strong. That rule makes a memory leak less mysterious than it first appears. If obsolete objects survive collection, some path still connects them to a root: a global map, an event listener, a timer, a queued callback, or another long-lived owner.

Heap graphs expose those paths. They replace the vague question “why is memory high?” with two concrete questions: which objects remain after their logical lifetime, and what root path retains them? The most reliable investigation uses comparable lifecycle snapshots, retained size and dominators to prioritize suspects, and a test that demonstrates reclamation after the ownership edge is removed. A large heap alone is not proof; unwanted reachability is.

Define the Leak by Object Lifetime

Memory use naturally rises while useful state accumulates. A cache warming to a configured limit, a batch being assembled, or a connection pool reaching steady state is not automatically a leak. A leak exists when objects outlive their intended lifecycle and retained memory grows with repeated work that should have been released.

Start with a lifecycle hypothesis. “After a client session closes, its request history and 2 MiB decode buffer should no longer be strongly reachable” is testable. “The process uses too much memory” is not. Identify the creation event, expected owner, release event, and object class or marker that can be found in a snapshot.

Observe the post-collection floor over repeated equivalent cycles. Managed heaps commonly form a sawtooth as allocation grows and collection releases dead objects. A rising peak can mean more temporary work; a rising floor after major collections suggests retention. Resident set size may remain high even when the managed heap is reusable because the runtime keeps pages, native libraries, thread stacks, and allocator arenas. Use runtime heap statistics to decide whether a heap graph is the right instrument.

Bounded retention needs a bound. A least-recently-used cache holding 10,000 entries may occupy substantial memory, but if it stabilizes and evicts correctly, its ownership is intentional. An unbounded map keyed by request ID can look like a cache while growing forever. Document maximum entries, bytes, or age, and test the policy under the cardinality actually expected.

The investigation should preserve correctness constraints. Clearing every map may flatten memory while destroying cache behavior or active session state. The goal is to align reachability with domain lifetime, not merely to minimize the heap.

Read a Heap as a Directed Graph

A heap snapshot models objects as nodes and references as directed edges. Roots are runtime-defined starting points such as global objects, active execution contexts, native handles, and persistent references held by the host. If a strong path exists from a root to a node, that node is live for collection purposes even when application code considers it obsolete.

Shallow size is memory directly attributed to one node. Retained size approximates the memory that could become unreachable if that node and its exclusive ownership were removed. A small Map wrapper can therefore have a tiny shallow size and retain hundreds of megabytes through its entries. Sorting only by shallow size often points at byte arrays while hiding the owner that keeps them alive.

A node d dominates node n when every path from the roots to n passes through d. In the dominator tree, each node is attached to its immediate dominator. Large retained size high in that tree is useful for prioritization: removing that ownership edge may release an entire subgraph. Dominance is structural evidence, not proof of a bug. The application’s legitimate root state will dominate most useful data too.

Consider this simplified graph:

flowchart LR R[Runtime roots] --> B[Event bus] B --> L[message listener] L --> C[closure] C --> S[closed Session] S --> H[request history] S --> D[decode buffer] R --> A[active sessions map] A --> S2[live Session]

The event bus may dominate the listener, closure, closed session, history, and buffer. The large buffer is the visible cost, but the stale listener edge is the cause. Deleting fields from Session could reduce symptoms while leaving the lifecycle error intact. Follow retaining paths outward until an edge crosses from intended long-lived ownership to state that should have ended.

Weak references appear differently because they do not normally keep their targets alive. However, replacing a strong map with a weak structure is not a universal fix: weak keys must be objects, iteration and size semantics differ, and collection timing is deliberately unspecified. First decide whether ownership is genuinely weak or whether the application needs explicit eviction.

Capture Comparable Lifecycle Snapshots

Snapshots are expensive and can pause the process or temporarily require substantial memory. Reproduce the issue in a controlled environment with representative data whenever possible. If production capture is unavoidable, use a canary or isolated instance, restrict access, estimate disk and pause impact, and treat snapshot contents as sensitive because strings and object fields may contain customer data.

Take snapshots at equivalent lifecycle boundaries:

  1. Start the process, initialize required services, and warm stable caches.
  2. Complete a full workload cycle, release its resources, and allow pending cleanup to run.
  3. Capture snapshot A after a collection boundary when the runtime permits a controlled test collection.
  4. Repeat the same workload and cleanup several times without changing cardinality.
  5. Capture snapshot B at the same boundary, then optionally repeat for snapshot C.

One before/after pair can be misleading. Lazy initialization may legitimately add one-time objects between A and B. A sequence reveals whether a class grows roughly with every cycle. Record workload count, active sessions, queue depth, cache entries, runtime version, flags, and snapshot timestamp so the files remain interpretable.

Forced collection is appropriate only in an isolated diagnostic process and only to normalize the comparison; production behavior should not depend on calling it. Without forced collection, wait for an observed major collection and correlate runtime telemetry. Do not compare one snapshot taken during peak request assembly with another taken after idle cleanup.

In the snapshot tool, begin with constructor or type deltas, but account for generic containers such as Object, arrays, strings, and closures. Domain marker fields or distinctive constructor names can make suspects searchable. Then inspect retained size and dominators. For a suspicious instance, request all retaining paths to roots and find the shortest path that explains ownership. Shortest paths are convenient, but another path may still retain the object after the first is fixed; re-snapshot after every repair.

Snapshot diffs are counts and sizes, not lifecycle truth. A positive delta for Session is suspicious only if active-session count did not also rise. Tie object growth to application invariants.

Work Through a Listener-Closure Leak

Imagine a gateway that creates a ClientSession for each connection. It registers a callback on a process-wide message bus and expects the session to disappear after disconnect. The callback closes over the session, but disconnect removes the session from the active map without unregistering the callback.

ts
import { EventEmitter } from 'node:events';

type Message = Readonly<{ clientId: string; body: Uint8Array }>;

const messageBus = new EventEmitter();
const activeSessions = new Map<string, ClientSession>();

class ClientSession {
  readonly history: Message[] = [];
  private readonly onMessage = (message: Message): void => {
    if (message.clientId === this.id) this.history.push(message);
  };

  constructor(readonly id: string) {
    activeSessions.set(id, this);
    messageBus.on('message', this.onMessage);
  }

  close(): void {
    activeSessions.delete(this.id);
    // The global bus still owns onMessage, whose closure owns this session.
  }
}

After repeated connect, message, and close cycles, a comparison may show increasing ClientSession, message array, and Uint8Array counts while activeSessions.size returns to zero. A retaining path reads conceptually:

text
runtime root
  -> messageBus
  -> listeners for "message"
  -> ClientSession.onMessage closure
  -> captured this
  -> ClientSession.history
  -> Message.body

The fix is lifecycle symmetry. The same owner that registers the listener must retain the exact callback identity and remove it during idempotent cleanup:

ts
class ClientSession {
  readonly history: Message[] = [];
  private closed = false;
  private readonly onMessage = (message: Message): void => {
    if (message.clientId === this.id) this.history.push(message);
  };

  constructor(readonly id: string) {
    activeSessions.set(id, this);
    messageBus.on('message', this.onMessage);
  }

  close(): void {
    if (this.closed) return;
    this.closed = true;
    messageBus.off('message', this.onMessage);
    activeSessions.delete(this.id);
    this.history.length = 0;
  }
}

Removing the listener repairs ownership. Clearing history reduces retained payload promptly and can be useful defense, but by itself it would leave one stale session and listener per connection. Idempotence matters because disconnect, timeout, and error paths may race to clean up. The real implementation should centralize those paths so every successful registration has one reliable release action.

Recognize Caches, Timers, and Queues as Owners

Listeners are only one form of long-lived ownership. Heap paths often end at a small set of patterns.

Caches retain values through maps or linked eviction structures. Look for missing maximum size, ineffective expiry, keys with unbounded cardinality, and rejected promises cached forever. Time-to-live alone is not a memory bound when insertion rate can spike before expiry. Prefer an explicit entry or byte limit, eviction metrics, and cleanup that removes auxiliary indexes as well as the primary entry.

Timers retain callback closures until canceled or fired. A repeating timer can retain its complete creation context for process lifetime. Store the handle, cancel it during owner disposal, and avoid capturing a large object when a small identifier is enough. A timer registry intended for cancellation can itself leak if completed handles are never removed.

Queues and promises retain pending work, arguments, and continuation closures. An overloaded queue may be bounded in theory but grow in practice because admission is missing. That is capacity retention rather than a forgotten edge, and the remedy is backpressure or rejection. Unsettled promises tied to operations that never resolve can look similar; enforce deadlines and detach listeners in every completion path.

Registries and observability can retain labels, request contexts, or trace buffers. Metrics keyed by raw user or request IDs create unbounded cardinality. Debug maps and “last requests” arrays are especially easy to leave uncapped. Diagnostic state requires the same lifecycle and privacy design as product state.

Detached UI or document structures survive when application registries, observers, or listeners still point at them. The graph may label a large subtree as detached, but the useful evidence remains the retaining path to the application owner.

Fix the highest ownership boundary you control. Nulling individual payload fields throughout a subgraph is brittle and can mask another path. Explicit dispose, close, or scoped ownership gives the release event a place to live and a behavior that tests can exercise.

Separate Heap Retention from Other Memory Growth

Heap snapshots cover the managed heap represented by the runtime. Process memory can grow elsewhere. Native buffers, memory-mapped files, image decoders, database drivers, WebAssembly memories, and allocator fragmentation may raise resident memory while managed retained size stays flat. Some runtimes account external byte-buffer backing stores separately from the wrapper visible in the graph.

Compare multiple signals: managed heap used after collection, heap committed, external or native memory counters, resident set size, object counts, and allocator or subsystem metrics. If resident memory rises while the post-collection managed heap is stable, investigate native allocation and fragmentation rather than forcing a heap-graph explanation. A small wrapper may still lead to an external allocation, so correlate wrapper lifetimes with subsystem bytes.

Allocation churn is another distinct problem. If each cycle allocates many temporary objects but the post-collection floor returns to baseline, there may be no leak. The cost appears as collector CPU and pauses. Use an allocation profile to find creation sites; a dominator snapshot prioritizes retained ownership and may miss high-rate short-lived objects entirely.

Also distinguish delayed cleanup from permanent retention. Finalizers, asynchronous close operations, and buffered telemetry may release state later. Measure the documented delay and bound it. Relying on nondeterministic finalization for scarce resources such as file descriptors is still unsafe; explicit cleanup should release those resources even if memory reclamation remains collector-controlled.

Prove Reclamation Instead of Watching One Graph Fall

A fix is complete only when it restores the lifecycle invariant. In a test-only process, run many identical session cycles, call close, drain scheduled cleanup, and capture or count after a normalized collection boundary. Active session and listener counts must return to their baseline. Retained ClientSession count should remain bounded rather than growing with completed cycles.

Weak references can support a reclamation test without becoming product ownership. Create sessions, retain WeakRef objects only in the test, drop strong local references, then allow repeated collection opportunities. Because collection and finalization timing are not guaranteed, do not assert that every target disappears after one forced collection or within a precise millisecond. Prefer a bounded-growth assertion plus eventual diagnostic evidence in a runtime configuration designed for the test.

Add direct lifecycle assertions that are deterministic:

  • listener count returns to its initial value after close;
  • the active-session map is empty;
  • repeated close calls do not throw or alter another session;
  • error, timeout, and normal-disconnect paths all invoke the same cleanup;
  • cache entry and byte limits hold under unique keys;
  • queued work is bounded when consumers stop.

Then repeat the original snapshot protocol. The stale dominator path should disappear, no alternative root path should retain the sessions, and consecutive post-cleanup snapshots should stabilize. Check application behavior too: a removed listener must not drop messages for sessions that are still active.

For operations, alert on rates and bounds that map to ownership: post-collection heap by active session, cache bytes versus limit, listeners per active connection, queued items, and managed versus resident memory. Use sustained windows because collectors and workloads are bursty. Keep snapshot capture behind restricted runbooks, redact or protect artifacts, and delete them according to data policy.

Heap graphs are effective because they make memory ownership inspectable. Define when an object should die, compare snapshots at that boundary, use retained size and dominators to find consequential owners, and trace every suspect to a root. Repair the lifecycle edge, then prove both structural cleanup and bounded memory over repetition. That process distinguishes a real leak from a merely large heap and turns reclamation into an invariant the system can defend.