Skip to content
Node.js8 min read

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.

bash
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 up

In 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.

Start your project

Have an idea? Let's ship it together.

Tell us what you're building — we'll reply within one business day with an honest take and a clear next step.