Finding and Fixing Node.js Memory Leaks
Sawtooth graphs, heap snapshots and the usual suspects. A practical method for hunting Node.js memory leaks, from first alert to verified fix.
A Node memory leak announces itself the same way every time: memory climbs steadily over hours or days, garbage collection gets more frantic and less effective, latency degrades, and eventually the container is killed and the graph resets. Restarting on a schedule is a tourniquet, not a treatment. Here is the method we use to actually find the leak, refined across a good number of client rescues.
First, confirm it is really a leak
Rising RSS alone is not proof — V8 happily holds onto freed memory rather than returning it to the OS. Look at heap-used after a manual GC, or trend heapUsed from process.memoryUsage() over a day. A genuine leak shows a rising floor: each sawtooth trough higher than the last. Also rule out the impostors first, because they are quicker to check: an unbounded in-memory cache, a queue with no consumer, and undersized --max-old-space-size for the workload all look like leaks from the dashboard.
Three-snapshot comparison
The core technique is comparative heap snapshots. Start the process with the inspector enabled, attach Chrome DevTools, and capture three snapshots: one at steady state, one after meaningful traffic, one after more traffic and a forced GC. The comparison view between snapshots two and three shows objects allocated in the window that survived collection — your leak is almost always near the top of that delta, sorted by retained size.
node --inspect=0.0.0.0:9229 dist/server.js
# then open chrome://inspect and take heap snapshots
# or capture programmatically on a signal:
kill -USR2 <pid> # with heapdump/v8.writeHeapSnapshot wired upIn production, taking snapshots pauses the event loop for seconds on a large heap, so capture from one drained instance, or use v8.writeHeapSnapshot on a worker you have taken out of the load balancer. The retainer tree for a leaked object then tells you exactly what is holding it — read it bottom-up until you hit a name from your own codebase.
When snapshots are too heavy or the leak is slow, allocation sampling is the gentler instrument: the DevTools allocation timeline attributes allocations to stack traces with modest overhead, and clinic.js wraps the same idea in a friendlier report. Sampling tells you where memory is being allocated; snapshots tell you why it is being retained. A stubborn hunt usually needs one of each.
The usual suspects
- Event listeners added per request on a long-lived emitter and never removed — the MaxListenersExceededWarning is Node telling you this directly.
- Module-level Maps and arrays used as caches with no eviction. Every cache needs a max size or a TTL; lru-cache exists for a reason.
- Closures captured by timers: setInterval callbacks referencing large objects keep them alive until the interval is cleared.
- Unsettled promises accumulating in arrays awaiting a Promise.all that never comes.
- Per-request state stored keyed by socket or session and never deleted on disconnect.
The event listener case is worth special mention because the fix is often one line: attach the listener once at startup rather than inside the handler, or use the once option. For genuinely per-request listeners, an AbortSignal passed to addEventListener gives you deterministic cleanup.
Verify the fix like you found the bug
A leak fix is not done when the code merges; it is done when the graph goes flat. Replay comparable traffic against the patched build — autocannon for an hour does fine — and confirm the heap floor stops rising across GC cycles. Then leave an alert on heap-used trend, because the second leak is usually hiding behind the first. Container memory limits deserve a final check too: V8 sizes its heap from what it can see of the machine, which may not match your container's allowance, so set --max-old-space-size to roughly three-quarters of the limit — otherwise the OOM killer fires before the garbage collector ever feels real pressure.
Chasing a memory graph that only goes up? STRCLI has done this hunt many times — we can help.