Development

How to Debug a Memory Leak in Node.js
(Real Production Case)

A container restarting itself every six hours isn't a Kubernetes quirk โ€” it's usually a memory leak wearing a disguise. Here's the exact process for finding one: from the first graph that gives it away to the heap snapshot diff that names the guilty line of code.

How to Debug a Memory Leak in Node.js

The Symptom: A Service That Restarts Itself Like Clockwork

Here's a pattern that shows up in almost every Node.js team's incident history sooner or later: an API service that runs fine for a few hours, then gets OOM-killed and restarted by the orchestrator. Nobody notices at first because Kubernetes just brings the pod back up and traffic recovers within seconds. Then someone checks the restart count on a dashboard and sees it climbing several times a day, every day, like a metronome.

That regularity is the tell. A one-off crash usually points to a bad deploy or a spike in traffic. A restart that happens on a near-identical schedule, day after day, regardless of load, points to something accumulating steadily in memory until it hits the container's limit โ€” a classic memory leak.

Step 1: Confirm It's Actually a Leak, Not Normal GC Behavior

Before chasing anything, pull up a memory graph for the process โ€” whatever you've got, whether that's a Grafana dashboard backed by Prometheus, your cloud provider's container metrics, or even process.memoryUsage() logged on an interval. You're looking for one specific shape.

Healthy Node.js memory usage looks like a sawtooth: it climbs as objects get allocated, then drops sharply when garbage collection runs, over and over, oscillating within a stable range. A leak looks like the same sawtooth pattern, except each GC cycle bottoms out a little higher than the last one. The floor keeps rising. Given enough time, that rising floor eventually crosses the container's memory limit and the process gets killed.

If your graph shows a rising floor rather than a stable one, you've confirmed the leak. Now you need to find what's holding onto memory it should be releasing.

Step 2: Reproduce It Somewhere You Can Actually Inspect

Debugging a leak directly in production is possible but painful โ€” you generally don't want to attach a heavy profiler to a service handling real user traffic. The better move is reproducing the growth pattern in staging or locally, under synthetic load, where you can take your time.

A simple load-testing script hitting the suspect endpoints repeatedly, combined with memory logging, is usually enough:

// memory-watch.js
setInterval(() => {
  const usage = process.memoryUsage();
  console.log({
    rss: Math.round(usage.rss / 1024 / 1024) + " MB",
    heapUsed: Math.round(usage.heapUsed / 1024 / 1024) + " MB",
    heapTotal: Math.round(usage.heapTotal / 1024 / 1024) + " MB",
    external: Math.round(usage.external / 1024 / 1024) + " MB",
  });
}, 10000);

Run your app with this alongside a load generator hitting a handful of routes in a loop. If heapUsed keeps trending upward over ten or fifteen minutes of steady, repeated requests instead of leveling off, you've reproduced the leak somewhere you can actually dig into it.

Step 3: Capture Heap Snapshots With the Built-In Inspector

Node ships with everything you need โ€” no extra dependencies required. Start your process with the inspector flag:

node --inspect index.js

Then open chrome://inspect in Chrome, click "Open dedicated DevTools for Node," and go to the Memory tab. The workflow that actually finds leaks is a comparison, not a single snapshot:

  1. Let the app sit idle for a moment, then take Heap Snapshot 1 as a baseline.
  2. Run your load generator for several minutes to trigger the suspected leak.
  3. Force garbage collection (DevTools has a trash-can icon for this) and take Heap Snapshot 2.
  4. Switch the view to "Comparison" and sort by # Delta, which shows which object types grew between the two snapshots.

Because you forced GC before the second snapshot, anything still showing a large positive delta wasn't cleaned up by garbage collection โ€” which means something in your code is still holding a reference to it. That's your leak, narrowed down to an object type.

Step 4: Walk the Retainer Tree

Once you've spotted a suspicious object type in the comparison view โ€” say, an array of request objects or a custom event object that keeps multiplying โ€” expand it and look at "Retainers." This tells you exactly what's holding a reference to each instance, which is what stops garbage collection from freeing it.

In practice, the retainer chain almost always leads to one of a handful of usual suspects:

  • An event listener that's never removed. Every call to emitter.on(...) without a matching off() or once() keeps whatever the listener closure captured alive for as long as the emitter exists.
  • A module-level cache or array with no eviction. A Map or plain object used as an ad-hoc cache that only ever gets written to, never pruned, grows forever under real traffic.
  • A closure captured by a long-lived timer. A setInterval that's never cleared keeps its entire closure scope โ€” including any large objects it references โ€” alive indefinitely.
  • Unbounded request-scoped data attached to a shared object. Pushing per-request data onto a shared array or singleton instead of scoping it to the request itself.

A Concrete Example: The Cache Nobody Evicted

One of the most common versions of this looks almost identical every time it shows up: a well-intentioned in-memory cache meant to save a database round trip, built without a size limit or expiry.

// Leaky: grows forever, one entry per unique key ever seen
const userCache = new Map();

async function getUser(id) {
  if (userCache.has(id)) return userCache.get(id);
  const user = await db.users.findById(id);
  userCache.set(id, user);
  return user;
}

In a heap snapshot comparison, this shows up as a steadily growing Map with a delta that tracks almost exactly with the number of unique users served since the process started. The retainer chain leads straight to the module-level userCache variable. The fix is to bound it โ€” either with a proper LRU cache or a simple time-based expiry:

// Fixed: bounded size with least-recently-used eviction
import { LRUCache } from "lru-cache";

const userCache = new LRUCache({
  max: 5000,
  ttl: 1000 * 60 * 10, // 10 minutes
});

async function getUser(id) {
  if (userCache.has(id)) return userCache.get(id);
  const user = await db.users.findById(id);
  userCache.set(id, user);
  return user;
}

Re-running the same load test after a fix like this should show the sawtooth pattern flatten back out โ€” each GC cycle returns close to the same floor instead of a rising one.

Other Tools Worth Knowing

  • --max-old-space-size: not a fix, but useful for confirming the container limit theory โ€” lowering this deliberately to reproduce the crash faster in a test environment.
  • clinic.js (specifically clinic heapprofiler): automates a lot of the snapshot-diffing workflow described above and produces a flame graph of allocations.
  • 0x: good for CPU flame graphs, and useful alongside heap analysis when a leak is also causing GC to eat CPU time as it works harder to reclaim shrinking headroom.
  • --expose-gc flag with global.gc(): lets you trigger garbage collection programmatically in a script, which is handy for automated before/after memory comparisons in CI.

Prevention: Catching the Next One Before It Ships

  • Treat any module-level Map, array, or object used as a cache as a liability by default โ€” give it a size cap or TTL as soon as it's created, not after it becomes a problem.
  • Pair every on() with a corresponding off() in cleanup code, especially in long-lived connections like WebSocket handlers.
  • Clear every setInterval and setTimeout you create, particularly ones tied to a request or connection lifecycle rather than the process lifecycle.
  • Add a memory-growth check to your load-testing pipeline so a leak surfaces in CI under sustained synthetic traffic, not three weeks later in production.
  • Alert on restart frequency, not just crashes โ€” a service quietly restarting itself every few hours is often the earliest visible signal you'll get.

The Takeaway

Memory leaks in Node.js are almost never mysterious once you have the right graph in front of you. The rising-floor sawtooth tells you a leak exists, a before/after heap snapshot comparison tells you what type of object is accumulating, and the retainer tree tells you exactly which line of code is holding the reference. Most leaks, in practice, come down to an unbounded cache, a forgotten listener, or a timer that outlives the thing it was scoped to โ€” and all three take one line to fix once you've actually found them.

Feedback

Live