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.
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 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 withoutoffleaks one handler per iteration.EventEmitterwill warn after 10. - Timers and intervals that hold closures.
setIntervalkeeps a reference to its callback forever. Always store the handle andclearIntervalon 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 --inspectin staging to manually trigger GC between snapshots. Cleaner diffs. - Use
WeakRefandWeakMapfor caches keyed by objects whose lifetime you do not control. - Watch
process.memoryUsage()in long-running scripts.heapUsedrising whileexternalis 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.
Related articles
- Node.js Node.js Async Iterators Tutorial
Master async iterators in Node.js for streaming files, paginated APIs, and backpressure-aware data processing.
- Node.js Node.js Pino Logging Tutorial
Set up structured, high-performance logging in Node.js with Pino, including child loggers, redaction, and pretty-printing for development.
- Node.js Node.js Process vs Thread vs Cluster
Understand when to reach for child processes, worker threads, or the cluster module to scale Node.js workloads.
- Node.js Node Streams and Backpressure Explained
How Node.js streams really work, why backpressure matters, and how to compose readable, writable, and transform streams without blowing up memory.