Back to blog
Jun 2026·4 min readAI-generated

Debugging Memory Leaks in Node.js

#nodejs#performance#debugging

A Node process that slowly climbs in memory usage over hours or days, then eventually gets OOM-killed by the container orchestrator, is one of the more frustrating classes of bug — it never reproduces locally, the logs right before the crash look completely normal, and "restart the pod" makes the symptom disappear without fixing anything. Here's how to actually find the leak instead of just working around it.

What a "leak" means in a garbage-collected language

Node doesn't have manual memory management, so a leak isn't a missing free() — it's an object that's still reachable from somewhere the garbage collector considers a root (global scope, an active closure, a long-lived data structure) long after your code has logically finished with it. The GC can't collect it because, as far as it can tell, you might still use it. The bug is always "I'm still holding a reference to this," not "I forgot to release it."

The usual suspects

Growing collections with no eviction. A Map or array used as a cache that only ever gets .set()/.push() calls, never cleanup:

// leaks: every request adds an entry, nothing ever removes one
const responseCache = new Map();

app.get('/api/report/:id', async (req, res) => {
  if (!responseCache.has(req.params.id)) {
    responseCache.set(req.params.id, await generateReport(req.params.id));
  }
  res.json(responseCache.get(req.params.id));
});

Every unique id ever requested stays in memory forever. Fix it with a bound: an LRU cache that evicts the least-recently-used entry past a size limit, or a TTL that expires entries after a fixed time.

Event listeners that outlive what they're attached to. Node's EventEmitter keeps a strong reference to every listener you register. If you attach a listener inside a function that runs repeatedly, and never remove it, listeners accumulate — and each one closes over whatever variables were in scope, keeping those alive too.

// leaks: a new listener added every time this runs, old ones never removed
function watchConnection(connection) {
  dbPool.on('error', (err) => {
    logger.error(`Connection error: ${err.message}`, { connectionId: connection.id });
  });
}

Call watchConnection 10,000 times over the app's lifetime and dbPool is holding 10,000 listeners, each closing over its own connection object. Fix: remove the listener when you're done with it (emitter.off(event, handler)), or use emitter.once() if it should only ever fire once.

Timers that are never cleared. setInterval keeps its closure — and everything that closure references — alive for as long as the timer runs, which by default is forever.

// leaks if `session` objects are meant to be short-lived
function startHeartbeat(session) {
  setInterval(() => {
    session.lastSeen = Date.now();
  }, 30_000);
  // no reference to the interval kept anywhere — it can never be cancelled
}

If you don't keep the return value of setInterval/setTimeout, you've lost the only handle that lets you clearInterval/clearTimeout it later — the timer, and its closure, live until the process exits.

Closures over large objects when only a small piece is needed. A function that closes over an entire request or response object just to use one field keeps the whole object (and everything it references) alive as long as the closure exists.

// keeps the entire `req` object alive inside `processedLater`
function schedule(req) {
  const processedLater = () => console.log(req.user.id);
  queue.push(processedLater);
}

// only keeps the one value you actually need
function schedule(req) {
  const userId = req.user.id;
  const processedLater = () => console.log(userId);
  queue.push(processedLater);
}

Confirming it's actually a leak, not just normal GC sawtooth

Healthy Node memory usage isn't flat — it saws up and down as the GC runs. A leak is a trend: the baseline after each GC cycle keeps climbing over time, not just the peaks between cycles.

setInterval(() => {
  const { heapUsed, rss } = process.memoryUsage();
  console.log(`heapUsed=${(heapUsed / 1024 / 1024).toFixed(1)}MB rss=${(rss / 1024 / 1024).toFixed(1)}MB`);
}, 60_000);

Log this for a few hours under real traffic. If heapUsed after each dip keeps trending upward, you have a leak; if it oscillates around a stable range, that's normal GC behavior.

Finding the actual leaking object with heap snapshots

Once you know there's a leak, the tool for finding what is --inspect plus Chrome DevTools' heap profiler, comparing two snapshots taken minutes apart under load:

node --inspect server.js

Open chrome://inspect in Chrome, connect to the process, and under the Memory tab:

  1. Take a heap snapshot (baseline).
  2. Let the app run under realistic load for a few minutes.
  3. Take a second snapshot.
  4. Switch the comparison view to "Comparison" between the two — this sorts objects by how many new instances appeared and weren't collected.

The object type at the top of that comparison — usually something suspiciously specific, like your own Session class or a closure count that keeps growing — is almost always the leak. From there, DevTools' retainer tree shows why it's still reachable: which variable, array, or Map is holding the reference that's preventing collection. That retainer path is the actual answer — it tells you exactly which line of code to go fix.

A cheaper first pass: --max-old-space-size and crash-time snapshots

If you can't easily reproduce the leak under a debugger session, you can get a heap snapshot automatically at OOM time by running with:

node --max-old-space-size=512 --heapsnapshot-near-heap-limit=3 server.js

This writes up to 3 heap snapshots to disk right before the process would otherwise crash from hitting the memory ceiling — useful in production or staging where you can't attach a debugger live, but can grab the resulting .heapsnapshot file and load it into DevTools afterward for the same comparison analysis.

Takeaway

Every Node memory leak reduces to the same root cause: something is still holding a reference the code no longer needs. Caches without eviction, listeners without removal, timers without clearing, and closures over more than they need are the four places that reference almost always lives. Confirm the trend with process.memoryUsage() over time, then find the specific object with a heap snapshot comparison — guessing from the symptoms alone rarely points at the real line of code.

NewerUnderstanding the JavaScript Event LoopOlderA Practical Guide to Semantic Versioning