Skip to content

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

Aman Kumar Singh6 min read
Part 6 of 7From the Node.js Runtime Internals series
Node.js Memory Leaks and Profiling: Finding What Holds Memory — article by Aman Kumar Singh

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.

Key takeaways

  • V8 frees unreachable objects automatically, so a Node leak is not un-freed memory — it is memory you are unintentionally still referencing.
  • The common sources: unbounded module-level collections, listeners added but never removed, uncleared timers, and closures capturing more than expected.
  • Detect a leak from heap-used trending upward and not returning to baseline after GC — a ratcheting sawtooth under steady load.
  • Find the cause by comparing two heap snapshots taken before and after exercising the suspect path; what grew is the leak, and the retainer path shows why.
  • Prevent leaks with habits: bound every cache, remove listeners and clear timers, watch closure captures, and put heap metrics on a dashboard.

Frequently asked questions

What causes memory leaks in Node.js?

Since V8 frees unreachable objects automatically, a leak is memory you are still referencing unintentionally. The usual causes are module-level Maps or arrays that only grow, event listeners added without being removed, setInterval timers never cleared, and long-lived closures that capture large objects.

How do I know if my Node.js app has a memory leak?

Watch heap-used over time under steady load. Normal memory rises and falls as the garbage collector works; a leak shows as a baseline that keeps climbing and does not return after GC — a sawtooth ratcheting upward. Tracking heap metrics in monitoring makes this visible before the process gets killed.

How do I find the cause of a memory leak?

Use heap snapshot comparison. Take a snapshot, exercise the suspected path many times, force garbage collection, and take a second snapshot. Compare them — objects that grew substantially between the two are the leak. From there, trace the retainer path to the reference keeping them alive.

How do I prevent memory leaks in Node.js?

Bound every cache with a size limit or TTL instead of an unbounded growing Map, remove event listeners and clear intervals when their owner goes away, prefer once for one-shot events, be conscious of what long-lived closures capture, and put heap metrics on a dashboard to catch a slow climb early.

Related articles

Scaling Node.js with the Cluster Module: Using All Your Cores — Aman Kumar Singh
Node.js Streams and Backpressure: Processing Data Without Running Out of Memory — Aman Kumar Singh
Node.js Error Handling: Operational vs Programmer Errors — Aman Kumar Singh

Explore more on

Aman Kumar Singh

About the author

I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.