NodeJSFinding Memory Leaks

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"
V8 frees unreachable objects — a leak is something *reachable* that you no longer need
V8's garbage collector reclaims any object that can't be reached by following references from the "roots" (globals, the active call stack, etc.). So memory only leaks when something you no longer care about is *still referenced* from a live root — the GC sees it as in-use and keeps it. Every Node memory leak reduces to this: an object is held by a global array, a never-cleared cache, a registered-but-never-removed event listener, a closure, or a timer that still points at it. Finding a leak means finding *what reference* is keeping the dead object alive — the "retainer chain".
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

emitter.on() without off() — each retains its closure

Remove listeners; watch for the max-listeners warning

Timers

setInterval never clearIntervald holds its callback

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

JS
// ❌ 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 })
An unbounded cache is the #1 Node leak — every `Map`/`object` used as a cache needs a size or TTL limit
The most common production memory leak is a cache with no eviction policy — a `Map` or plain object that accumulates entries indefinitely because nothing ever removes them. It looks innocent and works fine in testing, then grows without bound under real traffic until the process is OOM-killed. **Any** structure used as a cache must have a bound: a maximum size, a time-to-live, or both (an LRU cache gives you both). The same discipline applies to listeners (`off` what you `on`), timers (`clearInterval`), and global arrays (trim them). If it grows per-request and never shrinks, it's a leak waiting to happen.
Confirming a leak — watch the heap trend

JS
// 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
A leak shows as heap that grows and never returns to baseline after GC — not a one-time rise
Memory naturally rises and falls — it climbs as work arrives and drops when GC runs. A *leak* is different: the **baseline** (the low point after GC) keeps creeping up over time and never returns to where it started, even when load is steady or idle. Watch `heapUsed` over minutes/hours: a sawtooth that trends flat is healthy; a sawtooth whose troughs keep rising is a leak. Don't panic over a single rise — Node holds onto memory it might reuse. It's the *unbounded upward trend* that confirms the problem.
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)

JS
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.js
The retainer chain is the answer — it names exactly what's holding the leaked object
Diffing two snapshots tells you *what* is leaking (which object type keeps growing); the **Retainers** view tells you *why* — it traces the chain of references from a GC root down to the leaked object. That chain is the smoking gun: it might read `(global) → cache (Map) → entry → User`, immediately revealing the unbounded `cache` as the culprit. This is far more reliable than reading code and guessing. For an OOM that crashes before you can snapshot interactively, `--heapsnapshot-near-heap-limit` ([diagnostics](/nodejs/diagnostics)) captures the bloated heap automatically at the moment of failure.
Prevention patterns
  • Bound every cachelru-cache with max/ttl; never an unbounded Map.

  • Pair every on with an off — and prefer { once: true } for one-shot listeners.

  • Clear timers — track and clearInterval/clearTimeout on shutdown and when done.

  • Avoid module-level mutable state that grows per request — it lives for the whole process.

  • Use WeakMap/WeakRef for associating data with objects without preventing their collection.

  • Set --max-old-space-size appropriately and monitor heap in production so leaks are caught early.

`WeakMap`/`WeakRef` let you reference objects without keeping them alive
When you need to associate metadata with an object but don't want to *keep that object alive*, a `WeakMap` (keys held weakly) or `WeakRef` lets the GC collect the object once nothing else references it — the weak entry disappears automatically. This is the right tool for per-object caches and lookup tables keyed by objects, precisely because it avoids the unbounded-growth trap a regular `Map` creates. Combined with bounded caches, listener cleanup, and heap monitoring, weak references round out a leak-resistant design.
Next
Avoid redundant work entirely: [Caching Strategies](/nodejs/caching-strategies).