
How to Find a Memory Leak in Node.js (in About Ten Minutes)
Mahmud Hasan
October 13, 2026
Your Node.js process starts the morning at 200 MB, climbs past a gigabyte by evening, and dies overnight. You restart it. The graph looks exactly the same tomorrow. So you double the container's memory and add a cron restart, and for a while the problem goes away.
V8's garbage collector works fine — it frees everything it can prove is unreachable. A Node.js leak means something is still reachable that you didn't expect. Somewhere, a reference you forgot about is keeping the data alive. Two heap snapshots and about ten minutes are all it takes to find which one.
First: make sure it's actually a leak
V8's garbage collector is deliberately lazy. A process whose memory rises, plateaus, and then sawtooths up and down is not leaking — the collector just hasn't felt like running yet. Chasing that "leak" is how people waste a week.
The check is cheap. Log process.memoryUsage() every minute for a few hours under real load and watch heapUsed:
setInterval(() => {
const m = process.memoryUsage();
console.log(new Date().toISOString(), {
heapUsed: Math.round(m.heapUsed / 1024 / 1024) + 'MB',
rss: Math.round(m.rss / 1024 / 1024) + 'MB'
});
}, 60000);
A sawtooth — rises, drops, rises, drops — is healthy. A staircase — rises and never comes back down — is your signal. And there's a decisive test most people skip: force a full garbage collection after the load stops (node --expose-gc, then call global.gc()). If memory falls back near its baseline, there was never a leak; the collector was just postponing work. If it stays high, you've confirmed one.
One more honest point before the tools: don't "fix" this with --max-old-space-size. Raising the heap limit buys time the same way a bigger bucket buys time for a dripping pipe. Useful as an emergency measure, useless as a diagnosis.
The two-snapshot method
This is the single most powerful technique in the Node.js diagnostics docs, and the whole thing takes minutes. The idea: take a heap snapshot, exercise the code you suspect, take another one, and diff them. Whatever grew between the two is your leak.
There are four ways to take a snapshot, straight from the official docs. Pick whichever fits where your process lives:
- Inspector. Start with
node --inspect, openchrome://inspect, go to the Memory tab, and take a heap snapshot. Simplest locally. - A signal. Start with
node --heapsnapshot-signal=SIGUSR2 index.js, thenkill -USR2 <pid>whenever you want a snapshot — no restart, no inspector. - From inside the process.
require('node:v8').writeHeapSnapshot()behind an HTTP handler you trigger yourself. On older Node (before v11.13), theheapdumpnpm package does the same job. - Automatically, at the edge.
--heapsnapshot-near-heap-limit=3writes snapshots on its own as the process approaches OOM — a safety net for the leak you can't reproduce on demand.
The workflow matters more than the method:
- Let the process warm up and finish bootstrapping.
- Exercise the suspected path once — it will make innocent initial allocations.
- Take snapshot one.
- Keep exercising that path for a while, nothing else.
- Take snapshot two.
- In Chrome DevTools, load the older snapshot first and the newer one second, switch the view from Summary to Comparison, and sort by Delta descending.
The constructors with the largest positive deltas are your leak. Click into one and read the retainers panel at the bottom — it shows the chain of references keeping the object alive. Follow the chain up until you hit your own code. That's the reference you forgot.
One warning, also from the docs, and it's worth taking seriously: building a snapshot pauses everything on the main thread (on a big heap, over a minute) and can double memory while it builds — enough to crash the process you're inspecting. Never snapshot the instance serving traffic. Snapshot a canary, a staging clone, or a dev box under realistic load.
The five patterns behind almost every leak
Once you've diffed a few snapshots, you start recognizing the shapes. Practitioner playbooks keep converging on the same five:
1. Unbounded caches. A Map keyed by user ID with nothing ever evicted. A million unique users means a million entries, and the heap has no opinion about that until it dies. The fix is a real cache with limits: lru-cache with { max, ttl }, or a WeakMap when the key is an object whose lifetime should decide the entry's lifetime.
// grows forever
const cache = new Map();
cache.set(userId, await db.findUser(userId));
// evicts on its own
const cache = new LRU({ max: 500, ttl: 5 * 60 * 1000 });
2. Event listeners added per request, never removed. An emitter.on('data', handler) inside a route handler, one per connection, none cleaned up. Node treats it as a warning for a reason — a MaxListenersExceededWarning is a leak alarm, not noise. The fix: emitter.off('data', handler) inside req.on('close', …), or .once() when one firing is enough.
3. Forgotten timers. A setInterval that pushes results into an array with no cap, or an interval that was supposed to be temporary and outlived the feature. The timer keeps the whole closure alive — nothing it references can be collected. Fix: return a cleanup function, and cap any array that a timer appends to.
4. Closures holding more than they need. A callback that only needs summary but closes over the 500 MB buffer summary was computed from. The entire buffer stays reachable because the closure's scope does. Fix: extract what you need, then release the rest.
function processLargeFile(data) {
const summary = transformData(data).summary;
data = null; // let the big thing go
return () => summary;
}
5. Accidental globals. A forgotten const in sloppy mode turns a variable into a property of globalThis, and it lives for the life of the process. ES modules are strict by default, which helps; a linter rule for undefined variables finishes the job.
If your snapshot diff shows a growing pile of plain objects or strings, start with patterns 1 and 4. If it shows emitters or closures tied to requests, start with 2 and 3.
When you don't know where to look: clinic.js
The snapshot diff is precise but assumes you know which path to exercise. When memory just climbs and you have no suspect, Clinic.js's HeapProfiler earns its keep: it records which functions allocated memory while your app runs under load, and renders it as a flamegraph where the widest blocks allocated the most.
npm install -g clinic
clinic heapprofiler --autocannon [ / ] -- node index.js
That starts your app, drives it with autocannon (10 connections for 10 seconds by default), then opens a report showing the allocation flamegraph. Find the widest block, read the function name, go look at it. And clinic doctor — same suite — correlates memory with CPU and event-loop lag, which is how you tell a leak apart from an app that's just doing a lot of work. Caveat: clinic has real overhead, so run it locally or in staging, confirm in production with snapshots.
In production, be boring
The diagnosis above is the interesting part; the production posture is deliberately unglamorous:
- Log
heapUsedandrsson a timer and alert on monotonic growth over hours — the graph is the early warning. - Never snapshot the process serving traffic — use a canary, or let
--heapsnapshot-near-heap-limitcatch the evidence automatically. - Set a memory ceiling with a restart policy (PM2's
max_memory_restart, a Kubernetes memory limit) as a guardrail, not a strategy. Exit code 137 — SIGKILL from the OOM killer — is the signature of a leak that outgrew it. - Keep any snapshot endpoint unreachable from the outside. A heap dump is a map of your process's memory.
Add the five-line memory logger to one service today, and the next time a graph climbs like a staircase, you won't be guessing. Two snapshots, a comparison diff, and the retainer chain will point at the reference you forgot — because it is always, in the end, a reference you forgot.
References
- Node.js — Using Heap Snapshot (official diagnostics docs)
- Node.js CLI docs — --heapsnapshot-near-heap-limit
- Nearform — Tracking Memory Allocation in Node.js (Clinic.js HeapProfiler)
- dev.to — Node.js Out of Memory: How to Fix and Find the Leak (Real Commands)
- Bugfender — How to Find and Fix JavaScript Memory Leaks
- curiositech — Node.js Memory Leak Hunting (practitioner playbook)
Comments
More in Web Development
Byte
Secret Pro Tips to Increase WiFi Speed - Optimize WiFi speed very easily!
Secret Pro Tips to Increase WiFi Speed - Optimize WiFi speed very easily!
'The internet has become as essential as oxygen in our daily lives. However, a slow WiFi connection can easily ruin your productivity and mood. Did you know that simply tweaking a few router settings can significantly boost your internet speed? Today, we will discuss advanced settings like channel width, transmit power, and other technical secrets that are rarely mentioned.'
Read moreByte
How to identify a best camera phone: Megapixels or sensor—which one is real?
How to identify a best camera phone: Megapixels or sensor—which one is real?
'How to recognize a good camera phone? Does more megapixels mean a better camera? Find out how to recognize a good camera phone..'
Read more
What is Cryptocurrency?How to Earn and Where to Use It?
'A comprehensive guide to understanding cryptocurrency, blockchain technology, and the best methods to earn and use digital assets in the US market.'
Read more