Overview

Node processes that grow steadily until the container gets OOM-killed are a specific kind of frustrating, because the service looks healthy right up until it doesn't. The heap grows slowly, restarts fix it temporarily, and nothing in your logs points at a cause.

Here's a process that usually finds it in under an hour.

First, confirm it's actually a leak

Plenty of things look like leaks and aren't. A service that grows from 80 MB to 300 MB over the first ten minutes and then plateaus is just warming up — V8's garbage collector is lazy by design and doesn't return memory to the OS aggressively.

A real leak keeps climbing. Log heap usage on a timer and look at the shape:

setInterval(() => {
  const mb = process.memoryUsage();
  console.log(JSON.stringify({
    rss: Math.round(mb.rss / 1024 / 1024),
    heapUsed: Math.round(mb.heapUsed / 1024 / 1024),
    heapTotal: Math.round(mb.heapTotal / 1024 / 1024),
    external: Math.round(mb.external / 1024 / 1024),
  }));
}, 60_000);

The number that matters is heapUsed. If it climbs in a sawtooth pattern that rises higher each cycle and never comes back down to the previous baseline, you have a leak. If it climbs and holds, you probably don't.

external climbing instead is a different problem — that's buffers and typed arrays, usually a stream or a database driver holding onto things.

Get a heap snapshot

Run the service with the inspector enabled:

node --inspect=0.0.0.0:9229 server.js

For a service in Docker, publish port 9229 and connect Chrome to chrome://inspect. Then take two snapshots: one after the service is warm, and one after it has been running long enough for the leak to show.

The comparison view is what you want — snapshots have a "Comparison" mode that shows the delta between two captures. Sort by "Delta" and the leaking object type is usually near the top.

If you can't keep a debugger attached, the heapdump package writes a snapshot to disk on demand:

const heapdump = require('heapdump');

process.on('SIGUSR2', () => {
  const file = `/tmp/heap-${Date.now()}.heapsnapshot`;
  heapdump.writeSnapshot(file, (err) => {
    console.log(err || `Wrote ${file}`);
  });
});

Then kill -USR2 <pid> twice, with a gap in between, and load both files into Chrome DevTools → Memory → Load.

The usual suspects

Almost every Node leak I've debugged falls into one of six categories.

CulpritWhat to look for
Unbounded cacheA module-level Map or object that only ever gets added to
Event listener accumulationemitter.listenerCount() growing over time
Timers never clearedsetInterval in a request handler without a matching clearInterval
Closures holding large objectsA callback that captures a request body or a big buffer
Global arraysMetrics, logs, or history accumulated in module scope
Unclosed resourcesDatabase clients or streams created per request and never released

The cache one

This is the most common by a wide margin, and it usually looks like this:

const cache = new Map();

function getUser(id) {
  if (cache.has(id)) return cache.get(id);
  const user = fetchUserSync(id);
  cache.set(id, user);   // never evicted
  return user;
}

Under a steady stream of distinct IDs, that map grows forever. Swap it for an LRU with a bound:

const LRU = require('lru-cache');

const cache = new LRU({
  max: 5000,
  ttl: 1000 * 60 * 5,
});

If you genuinely need all entries, that's fine — but put it behind something that persists, not a process that restarts.

The listener one

Attaching a listener inside a function that runs per-request is the classic way to leak. Node prints a warning at eleven listeners, but only for a single event name on a single emitter, so it's easy to miss:

function handleRequest(req, res) {
  process.on('SIGTERM', () => {
    // a new listener added on every single request
  });
}

Find them by dumping listener counts:

const counts = {};
for (const name of process.eventNames()) {
  counts[name] = process.listenerCount(name);
}
console.log(counts);

If any number climbs across requests, that's your leak.

Reading a snapshot without drowning

Heap snapshots are enormous and mostly noise. A few filters make them tractable:

  • Switch to Comparison view. Absolute sizes tell you what's big; deltas tell you what's growing.
  • Filter by Constructor and look for your own class names. If Object is at the top with tens of thousands of new instances, expand it and look at the retained paths.
  • Ignore internals like (compiled code), (system), and ArrayBuffer unless external memory was the thing climbing.
  • Click an object and read the Retainers panel at the bottom. That's the chain of references keeping it alive — the actual answer to "why isn't this collected."

Testing a suspected fix

Once you think you've found it, verify rather than assume. A rough load test against the endpoint that triggers the suspect code, with heap logging on, is enough:

for i in $(seq 1 20000); do
  curl -s http://localhost:3000/api/endpoint > /dev/null
done

Compare heapUsed before and after. If it returns to roughly the starting point after a forced GC (node --expose-gc and global.gc()), the fix is real.

Preventing the next one

Set a container memory limit and let the orchestrator restart the process when it's exceeded. That's a bandage, not a fix, but it converts "the whole node goes down" into "one pod restarts," which buys you time to find the actual cause. Add the heap logging permanently — it's two lines and it turns a mystery into a graph.