Skip to content
C Codeloom
Node.js

Debugging Node.js Memory Leaks

Find and fix memory leaks in Node.js using heap snapshots, sampling, and a few reliable patterns to avoid leaks.

·4 min read · By Codeloom
Intermediate 10 min read

What you'll learn

  • What a leak looks like
  • Heap snapshots in practice
  • Common leak shapes
  • Closures and listeners
  • When to use weak references

Prerequisites

  • Familiar with Node.js and Chrome DevTools

What and Why

A memory leak in Node.js means your process holds on to objects it no longer needs. The garbage collector cannot free them because something is still referencing them. Over time, RSS grows, GC pauses get longer, and eventually your process is killed by the OS or by Kubernetes.

You spot a leak as a steadily rising memory chart that never falls back after traffic drops. The fix is almost never “increase the heap size.” Sooner or later you have to find what is holding the references.

Mental Model

V8 has a young generation for short-lived objects and an old generation for survivors. Short-lived garbage is collected quickly and cheaply. Long-lived objects pile up in the old space and require more expensive GC cycles. A leak is a stream of allocations that get promoted to old space and never freed because some retainer is still pointing at them.

Your job in debugging is to find the retainer. A heap snapshot shows you which objects exist and what is keeping them alive. A timeline of snapshots shows you what is growing between two points in time.

Hands-on Example

A classic accidental leak. A module-level cache that never evicts.

const cache = new Map();

export function getUser(id) {
  if (!cache.has(id)) {
    cache.set(id, fetchUserFromDb(id));
  }
  return cache.get(id);
}

Each unique ID adds a permanent entry. In a server that serves millions of IDs, the cache grows forever.

Take a heap snapshot in production.

# Send SIGUSR2 to get a heap snapshot (Node 12+)
kill -USR2 <pid>

Or programmatically.

import v8 from 'node:v8';
import fs from 'node:fs';

setInterval(() => {
  const stream = v8.getHeapSnapshot();
  stream.pipe(fs.createWriteStream(`./heap-${Date.now()}.heapsnapshot`));
}, 60_000);

Open the snapshot in Chrome DevTools (Memory tab). Take two snapshots a minute apart, then use “Comparison” view. Sort by ”# Delta.” The classes that grew steadily are your suspects. Click in, follow the retainer chain back to the root, and you usually find the cache, the listener, or the closure responsible.

Symptom: RSS rises
     |
     v
Take snapshot A -> wait -> Take snapshot B
     |
     v
Compare deltas -> find growing class
     |
     v
Follow retainers -> identify holder -> fix code
Leak hunting workflow

Fix the cache with a bounded LRU.

import { LRUCache } from 'lru-cache';
const cache = new LRUCache({ max: 10_000, ttl: 60_000 });

Common Pitfalls

  • Event listeners that are never removed. Calling emitter.on('data', handler) inside a loop without off leaks one handler per iteration. EventEmitter will warn after 10.
  • Timers and intervals that hold closures. setInterval keeps a reference to its callback forever. Always store the handle and clearInterval on shutdown.
  • Closures over large objects. A small callback that closes over a big DOM or request object keeps the big thing alive.
  • Caches without bounds. Maps, plain objects, and arrays grow indefinitely unless you cap them.
  • Global state in tests. Module-level state survives between tests, sometimes hiding leaks until production.

Practical Tips

  • Add node --expose-gc --inspect in staging to manually trigger GC between snapshots. Cleaner diffs.
  • Use WeakRef and WeakMap for caches keyed by objects whose lifetime you do not control.
  • Watch process.memoryUsage() in long-running scripts. heapUsed rising while external is flat usually points to JS-side leaks.
  • For native modules, set NODE_OPTIONS=--max-old-space-size=... only to buy time while you investigate.
  • Reproduce leaks with a load test. A short script that calls the suspect endpoint 100k times will surface the leak in minutes.
import autocannon from 'autocannon';
await autocannon({ url: 'http://localhost:3000/users/1', amount: 100000 });

Wrap-up

Memory leaks in Node.js are almost always about retaining what you no longer need. Caches without bounds, listeners without removal, and timers without cleanup are responsible for the vast majority of cases. Take heap snapshots, compare them over time, and follow the retainer chain to the responsible code. Replace unbounded caches with LRUs, remove listeners when you are done with them, and your process will keep its memory under control even under heavy load.