DEV Community

Aman Kumar Singh
Aman Kumar Singh

Posted on • Originally published at amanksingh.com

Node.js Memory Leaks and Profiling: Finding What Holds Memory

A Node.js memory leak rarely announces itself. The service runs fine in testing, ships, and then over hours or days its memory creeps up until it hits the limit and the process is killed and restarted. Because a restart temporarily fixes it, leaks often hide for a long time behind an automatic restart policy, quietly costing you reliability and money. Finding them is a specific skill: you have to know the handful of patterns that cause leaks in Node, and the tools that let you see what is actually holding memory.

This is part of the Node.js runtime series. Here we cover how memory works in Node, the common leak sources, and how to profile.

How memory works in Node

Node uses V8's garbage collector, which automatically frees objects that are no longer reachable — that is, objects nothing in your running program can still reference. You do not free memory manually; you just stop referencing things, and the collector reclaims them. This is why a "leak" in a garbage-collected runtime is not memory you forgot to free. It is memory you are unintentionally still referencing. Something is holding a reference to objects you are done with, so from the collector's point of view they are still in use and cannot be reclaimed.

That reframing is the key to finding leaks. You are not hunting for a missing free(). You are hunting for a reference that should have been dropped and was not — a growing structure, a listener never removed, a closure that captured more than you realized. Almost every Node leak is one of a few recognizable shapes.

The common leak sources

Unbounded growth of a module-level structure. The most common leak by far: a Map, array, or object at module scope that you keep adding to and never remove from. A cache that never evicts, a Map keyed by request ID that you populate but never delete, an array you push to on every event. Each entry holds its objects alive forever. Any collection that only ever grows is a leak waiting to happen; every cache needs an eviction policy or a size bound, which is exactly why Redis caches use TTLs rather than growing without limit.

Listeners that are added but never removed. Every emitter.on(...) holds a reference to its listener function, and through the closure, to everything that function captured. Add listeners without removing them — on every request, say — and they accumulate, along with everything they close over. Node even warns about this: the "possible EventEmitter memory leak detected, 11 listeners added" message is a direct signal you are registering listeners you never clean up. The fix is to remove listeners when you are done, or use once for one-shot events so they clean themselves up.

Timers and intervals never cleared. A setInterval that is never cleared runs forever and keeps its callback — and its captured scope — alive for the life of the process. If you create intervals or timeouts tied to some resource, clear them when that resource goes away.

Closures holding more than expected. A closure keeps alive everything in the scope it captured, even parts you do not use. A callback stored somewhere long-lived that happened to capture a large object keeps that whole object in memory. This one is subtle because the capture is implicit, and it is often the answer when a leak does not match the more obvious patterns.

Detecting that you have a leak

Before profiling, confirm the leak is real and get a shape for it. Watch the process's memory over time under normal load. The signal of a leak is heap usage that trends upward and does not come back down after garbage collection — a sawtooth that ratchets higher rather than returning to a stable baseline. Normal memory rises and falls as the collector works; a leak is the baseline steadily climbing. Your monitoring and structured logs should track heap-used over time so this trend is visible on a graph rather than discovered when the process gets killed. If memory grows unbounded under steady load, you have a leak worth chasing.

Profiling with heap snapshots

The core tool for finding what is holding memory is the heap snapshot: a complete picture of every object in the heap at a moment in time. You capture one through the Chrome DevTools inspector (start Node with --inspect and connect from Chrome, or use the v8 module or a library to write snapshots to disk) or through your platform's profiler.

A single snapshot tells you what is in memory now, but the powerful technique is comparison. Take a snapshot, exercise the suspected leaky path many times, force a garbage collection, and take a second snapshot. Then compare the two. Anything that grew substantially between the snapshots — thousands more of some object than before — is your leak, or the path to it. The comparison cuts through the noise of everything that is legitimately in memory and points at what is accumulating. From the growing object, the snapshot lets you trace the retainer path: the chain of references keeping it alive, which leads you straight back to the module-level Map, the un-removed listener, or the uncleared interval that is the actual cause.

For CPU issues rather than memory, the equivalent tool is a CPU profile, which shows where execution time is spent and is how you find the function pinning a core — often the same investigation that reveals work that should move to a worker thread.

Preventing leaks in the first place

Most leaks are prevented by a few habits rather than found by heroic debugging. Bound every cache — a size limit or a TTL, never an unbounded growing Map. Remove listeners and clear intervals when the thing they belong to goes away, and prefer once for one-shot events. Be conscious of what your long-lived closures capture. And put heap metrics on a dashboard so a slow climb is visible early, while it is a graph trending up rather than a 3 a.m. page about a process that keeps dying.

The mental model to carry: in Node, memory is not freed, it is released by becoming unreachable, so a leak is always a reference you failed to drop. When memory climbs, the question is never "what did I forget to free" but "what am I still holding onto." Answer that — usually with a snapshot comparison pointing at a growing collection — and the fix is almost always to drop the reference: evict the cache entry, remove the listener, clear the timer. It ties back to the same discipline that runs through the whole runtime: understand what the process is actually holding, and keep it bounded.


Originally published at amanksingh.com.

Top comments (0)