DEV Community

RAXXO Studios
RAXXO Studios

Posted on Originally published at raxxo.shop

Debugging a Memory Leak in a Long-Running Node Process

  • A healthy Node process has a sawtooth memory graph; a rising floor means a leak

  • Take two heap snapshots and diff them to find the growing object class

  • The three usual suspects: unremoved listeners, unbounded caches, closures holding big objects

  • Fix the retained path, not the symptom, then rerun the same snapshot test

A long-running Node process that slowly eats memory until it crashes is one of the most annoying bugs I deal with. Nothing in the logs. No stack trace. Just a slow climb until the process gets killed. Here's how I find the leak instead of guessing.

Reading the Memory Graph First

Before I touch a single snapshot, I look at the shape of memory over time. A healthy process has a sawtooth pattern. Memory climbs as objects get allocated, then drops when garbage collection runs. Up, down, up, down. The peaks and valleys stay roughly level over hours.

A leak looks different. The valleys creep up. Each garbage collection cycle frees less than the last. The floor rises. That rising floor is your signal, and you can see it before the process ever dies.

I get this graph the cheap way first. A setInterval that logs process.memoryUsage().heapUsed every 30 seconds, piped to a file. Then I plot it or just eyeball the numbers. If heapUsed after each GC keeps climbing over an hour, I have a real leak and not just a busy workload.

One trick that saves time: force garbage collection and measure right after. Start the process with --expose-gc, then call global.gc() before reading memoryUsage(). Now you're measuring retained memory, the stuff GC can't reclaim. That's what actually matters. Normal allocation spikes are noise. Retained growth is the bug.

If the floor is flat, stop.

You don't have a leak. You might have a workload that genuinely needs more heap, and the fix is raising --max-old-space-size or doing less work at once. I've wasted afternoons hunting a "leak" that was just a 400,000-row import holding everything in memory on purpose.

Numbers help here. I like to see at least three GC cycles with a rising floor before I commit to a snapshot session. One bump could be a cache warming up. Three rising valleys over ten minutes is a pattern.

Once I'm sure the floor rises, I know the next step is finding which objects aren't getting freed. The graph tells me a leak exists. It doesn't tell me what's leaking. For that I need snapshots. The graph is the smoke alarm, and the heap diff is walking into the room to find the fire.

Two Snapshots and a Diff

The whole technique comes down to this. Take a heap snapshot. Run the leaky workload for a while. Take a second snapshot. Compare them. Whatever grew between the two is your suspect.

In Chrome DevTools you attach to the Node process with node --inspect and open the Memory tab. Take snapshot one. Let the process handle a few hundred requests, or whatever triggers the growth. Take snapshot two. Then switch the comparison view to "Objects allocated between snapshot 1 and snapshot 2."

That comparison view is the whole game.

It sorts by what was allocated and still alive. You're looking for a class of object with a high positive delta. If Array grew by 50,000 and the "# Delta" column stays positive, something is holding those arrays and never letting go.

The column I watch most is "Retained Size." Shallow size is the object itself. Retained size is everything that object keeps alive, the whole chain that can't be freed because this one thing points at it. A small closure with a huge retained size is the classic signal. The closure is tiny, but it's pinning megabytes.

Click the suspect object and look at the retainers panel at the bottom. This is the path from your object back to a GC root, the reason it's still in memory. Follow it up. You'll usually land on a familiar name: an event emitter, a Map you declared at module scope, or a variable captured in a callback you forgot about.

Do this with real volume. A diff between two snapshots with ten objects each tells you nothing. I run the workload hard enough that the leak shows a delta in the thousands. Signal beats subtlety here.

If you want to script this without DevTools, the v8 module writes snapshots to disk with v8.writeHeapSnapshot(). Call it twice around your workload, then load both .heapsnapshot files into DevTools offline. Handy for production boxes where you can't attach a live inspector. I've covered a related production-safety pattern in Writing Idempotent Webhooks, since leaks and retries both bite long-running processes.

The Three Usual Suspects

Almost every leak I've found lands in one of three buckets. Once you know the shapes, the retainer path confirms which one in seconds.

First, listeners that never get removed. You call emitter.on('data', handler) inside a function that runs per request, and you never call removeListener. Every request adds another handler. The emitter's listener array grows forever, and each handler drags its closure scope along. Node even warns you here: "MaxListenersExceededWarning: 11 data listeners added." That warning is a leak announcing itself. Pair every on with an off, or use once when the event fires a single time.

Second, caches with no eviction. A Map at module scope that you only ever write to. It feels like an optimization. You cache a parsed config, a compiled template, a database result keyed by user ID. The keys are unbounded, so with enough distinct keys the map holds everything you've ever seen.

The fix is a size limit. An LRU cache that evicts the oldest entry at, say, 1,000 keys turns an unbounded leak into a fixed ceiling. If the cache is request-scoped, don't put it at module scope at all.

Third, closures holding big objects. This is the sneaky one. You grab a huge response body, pull one small field out of it, and return a callback that happens to reference the whole body. The callback outlives the request, so the entire body stays alive to keep one string. In the retainers panel this shows as a tiny function with an enormous retained size. The fix is to copy out the small field and let the big object go out of scope. Don't close over what you don't need.

A fourth honorable mention: timers. A setInterval you never clear keeps its callback and everything it references alive for the life of the process. If you create intervals dynamically, track and clear them. I ran into timer lifetime issues when debugging Scheduled Jobs and Time Zones, where the timer survived long after its job should have ended.

Fixing the Retained Path, Not the Symptom

The mistake I see most is patching the symptom. Memory climbs, so someone raises --max-old-space-size from 512 to 2048 and calls it fixed. It's not fixed. It's delayed. A leak with a bigger heap just crashes later, usually at 3am under load, which is the worst time to learn you never fixed it.

Fix the retained path. The retainers panel told you exactly what's holding the object. Break that link by removing the listener, bounding the cache, nulling out the closure reference, or clearing the timer. One of those cuts the chain, and the object becomes collectable again.

Then prove it.

Don't trust your eyes on the memory graph after a fix, because confirmation bias is strong and a slow leak can look flat for ten minutes. Rerun the exact snapshot test. Snapshot one, run the same workload at the same volume, snapshot two, diff. The delta that was in the thousands should now be near zero. If it isn't, you fixed a leak but maybe not the leak. Big processes often have more than one.

I turn that snapshot test into a repeatable check. A small script that forces GC, records heapUsed, hammers the workload 5,000 times, forces GC again, and compares. If retained memory grew more than some threshold, it fails. Now a future change that reintroduces a leak gets caught before it ships. This is the same instinct behind the catalog work in Binary Search vs Array.includes(): measure, change one thing, measure again.

A few guardrails I keep in my projects. I set emitter.setMaxListeners() deliberately rather than bumping it to silence warnings. I give every cache an explicit size bound on the day I create it, not after it leaks. And I treat any closure that captures a large payload as a smell to check.

If you're building Node tooling with an AI assistant and want it to catch these patterns while you write, my Claude Blueprint walks through the setup I use to keep an agent honest about memory and lifecycle bugs.

Bottom Line

Start with the graph, because it tells you whether a leak even exists and saves you from chasing a workload that just needs more heap. If the floor after each GC keeps rising, take two heap snapshots around the leaky work and read the diff. The retainers panel hands you the exact reason an object survives, and nine times out of ten it's a listener, a cache, or a closure.

The part people skip is proving the fix. Rerun the same snapshot test at the same volume and watch the delta drop to near zero. If you can, bake that check into a script so the next leak fails fast instead of crashing at 3am.

Next time a process climbs for no reason, don't raise the heap limit and hope. Force a GC, snapshot, run the workload, snapshot again, and let the diff point at the one object that won't go away.

Top comments (0)