Finding Memory Leaks
A memory leak in Node is memory that's no longer needed but can't be garbage-collected because something still references it. V8's garbage collector frees any object that's unreachable — so a leak is always a reachability bug: a reference you forgot to release. Over time the heap grows until the process slows under GC pressure and eventually crashes with an out-of-memory error. This page covers how GC reachability works, the classic leak sources in Node, how to confirm a leak with heap snapshots, and the patterns that prevent them.
Why leaks happen — reachability, not "unused"
The classic leak sources
Source | Why it leaks | Fix |
|---|---|---|
Unbounded cache/Map | Entries added, never evicted — grows forever | Use an LRU cache with a max size / TTL |
Global arrays | Pushing to a module-level array that's never trimmed | Bound the size; question why it's global |
Event listeners |
| Remove listeners; watch for the max-listeners warning |
Timers |
| Clear timers on shutdown / when done |
Closures | A closure captures a large object it doesn't need | Null out references; narrow what the closure captures |
Detached references | Caching request/socket objects beyond their lifetime | Don't retain per-request objects globally |
// ❌ Leak: a module-level cache that only ever grows:
const cache = new Map()
export function getUser(id) {
if (!cache.has(id)) cache.set(id, fetchUser(id)) // never evicted!
return cache.get(id)
}
// ❌ Leak: a listener added per request, never removed:
function handle(req) {
emitter.on('event', () => process(req)) // accumulates forever
}
// ✅ Bounded cache with eviction:
import { LRUCache } from 'lru-cache'
const cache = new LRUCache({ max: 1000, ttl: 60_000 })Confirming a leak — watch the heap trend
// Log heap usage periodically — a steadily rising baseline = leak:
setInterval(() => {
const { heapUsed, rss } = process.memoryUsage()
console.log(`heapUsed=${(heapUsed / 1e6).toFixed(1)}MB rss=${(rss / 1e6).toFixed(1)}MB`)
}, 10_000)heapUsed=48.2MB rss=92.1MB ← after warmup heapUsed=71.5MB rss=128.4MB ← climbing under steady load... heapUsed=103.8MB rss=171.2MB ← never returns to baseline → leak
Finding the cause — heap snapshot diffing
Take a heap snapshot (Chrome DevTools Memory panel, or
v8.writeHeapSnapshot()).Exercise the leaking operation many times (hundreds/thousands of iterations).
Take a second snapshot and use the Comparison view to see which object types grew.
Sort by Delta / # New — the constructor that keeps growing is your leaking object.
Select a growing object and follow its Retainers — the reference chain keeping it alive is the bug.
Write a heap snapshot on demand (load it into DevTools Memory tab)
import v8 from 'node:v8'
// Trigger via a signal or admin endpoint, before & after the suspect operation:
const file = v8.writeHeapSnapshot() // e.g. Heap.20260621.123456.0.001.heapsnapshot
console.log('wrote', file)
// Or auto-capture as the process approaches the heap limit (OOM forensics):
// node --heapsnapshot-near-heap-limit=2 app.jsPrevention patterns
Bound every cache —
lru-cachewithmax/ttl; never an unboundedMap.Pair every
onwith anoff— and prefer{ once: true }for one-shot listeners.Clear timers — track and
clearInterval/clearTimeouton shutdown and when done.Avoid module-level mutable state that grows per request — it lives for the whole process.
Use
WeakMap/WeakReffor associating data with objects without preventing their collection.Set
--max-old-space-sizeappropriately and monitor heap in production so leaks are caught early.