Memory leaks
A leak is not memory that is high, it is memory that only goes up. Steady
high usage is a capacity question. A slope that never returns to baseline
after load stops is a reference someone forgot to drop, and guessing which
one wastes hours. Measure the growth, then let the heap name the culprit.
Method
- Confirm it is a leak, not a plateau. Plot resident memory (RSS) over
a load cycle with
ps, /proc/<pid>/status, or your platform metrics.
A leak keeps climbing after the workload ends and never recovers across
several GC cycles. A cache filling to its cap and holding is not a leak.
- Capture two heap snapshots under identical load. Take one after
warmup, run a fixed number of operations, take a second. In Node use
--inspect and Chrome DevTools or v8.writeHeapSnapshot(); in Python
use tracemalloc.take_snapshot(); on the JVM use jmap -dump:live.
The live or comparison mode matters: you want survivors, not garbage.
- Diff the snapshots, sort by retained size delta. DevTools comparison
view,
tracemalloc.compare_to(old, 'lineno'), or Eclipse MAT on a JVM
dump. The type whose instance count grows by roughly your operation count
is the leak. Ten thousand Listener objects after ten thousand requests
is the answer pointing at itself.
- Follow the retention path to a GC root. Right-click the leaked object
and read "Path to GC Roots" (MAT) or the retainers tree (DevTools). The
path ends at whatever holds the reference: a module-level list, an event
emitter's handler array, a closure captured in a cache, a static map.
That holder is the bug, not the leaked object.
- Cut the strongest reference, not every reference. Remove the listener
on teardown, bound the cache with an LRU or size cap, use
WeakMap/
WeakReference for incidental associations, or clear the collection when
the owning scope ends. Fix the one edge on the retention path.
- Re-run the two-snapshot cycle to prove the slope is flat. Same load,
same operation count. Retained delta near zero means fixed. A smaller but
nonzero slope means a second leak: repeat from step 3.
Signals
- After N operations, does exactly one type grow by about N instances?
- Does RSS return to its warmup baseline after load stops and a forced GC?
- Does every leaked instance trace to the same holder on the retention path?
Boundaries
Native allocations (C extensions, off-heap buffers, memory-mapped files) do
not show in a managed-heap snapshot; reach for Valgrind, ASan, or jemalloc
profiling there. Fragmentation and allocator overhead can raise RSS without
any leak, so trust the object-count trend over the raw RSS number.
1---2name: memory-leaks3description: Find the object that never gets freed by comparing heap snapshots and following retention paths. Use when a process grows in memory over time, gets OOM-killed, or slows under a garbage collector that keeps working harder.4---56# Memory leaks78A leak is not memory that is high, it is memory that only goes up. Steady9high usage is a capacity question. A slope that never returns to baseline10after load stops is a reference someone forgot to drop, and guessing which11one wastes hours. Measure the growth, then let the heap name the culprit.1213## Method14151. **Confirm it is a leak, not a plateau.** Plot resident memory (RSS) over16 a load cycle with `ps`, `/proc/<pid>/status`, or your platform metrics.17 A leak keeps climbing after the workload ends and never recovers across18 several GC cycles. A cache filling to its cap and holding is not a leak.192. **Capture two heap snapshots under identical load.** Take one after20 warmup, run a fixed number of operations, take a second. In Node use21 `--inspect` and Chrome DevTools or `v8.writeHeapSnapshot()`; in Python22 use `tracemalloc.take_snapshot()`; on the JVM use `jmap -dump:live`.23 The `live` or comparison mode matters: you want survivors, not garbage.243. **Diff the snapshots, sort by retained size delta.** DevTools comparison25 view, `tracemalloc.compare_to(old, 'lineno')`, or Eclipse MAT on a JVM26 dump. The type whose instance count grows by roughly your operation count27 is the leak. Ten thousand `Listener` objects after ten thousand requests28 is the answer pointing at itself.294. **Follow the retention path to a GC root.** Right-click the leaked object30 and read "Path to GC Roots" (MAT) or the retainers tree (DevTools). The31 path ends at whatever holds the reference: a module-level list, an event32 emitter's handler array, a closure captured in a cache, a static map.33 That holder is the bug, not the leaked object.345. **Cut the strongest reference, not every reference.** Remove the listener35 on teardown, bound the cache with an LRU or size cap, use `WeakMap`/36 `WeakReference` for incidental associations, or clear the collection when37 the owning scope ends. Fix the one edge on the retention path.386. **Re-run the two-snapshot cycle to prove the slope is flat.** Same load,39 same operation count. Retained delta near zero means fixed. A smaller but40 nonzero slope means a second leak: repeat from step 3.4142## Signals4344- After N operations, does exactly one type grow by about N instances?45- Does RSS return to its warmup baseline after load stops and a forced GC?46- Does every leaked instance trace to the same holder on the retention path?4748## Boundaries4950Native allocations (C extensions, off-heap buffers, memory-mapped files) do51not show in a managed-heap snapshot; reach for Valgrind, ASan, or jemalloc52profiling there. Fragmentation and allocator overhead can raise RSS without53any leak, so trust the object-count trend over the raw RSS number.