Finding a Node.js Memory Leak With Heap Snapshots

Memory climbs, the process gets killed, and restarting fixes it for six hours. Here is the diff based method that finds the retainer.

Share
Finding a Node.js Memory Leak With Heap Snapshots. Abstract node.js illustration in orange and dark grey on debugly.dev

The short answer

Take two heap snapshots twenty minutes apart under steady load, load both into Chrome DevTools, and use Comparison view sorted by size delta. The object type that grew is your leak, and the Retainers panel tells you what is holding it.

node --inspect server.js
# or, without stopping the process:
kill -USR2 <pid>   # if you wired up a signal handler, shown below

The four causes that account for most Node leaks: listeners added per request, an unbounded Map or array used as a cache, closures capturing large objects, and timers that are never cleared.

Tested on Node 22.14.

First, confirm it is actually a leak

Memory going up is not a leak. V8 grows the heap until it hits a threshold and only then collects aggressively, so a sawtooth pattern is healthy.

A leak looks like a staircase: each collection frees less than the last, and the floor rises.

setInterval(() => {
  const m = process.memoryUsage();
  console.error(JSON.stringify({
    t: new Date().toISOString(),
    rss: Math.round(m.rss / 1e6),
    heapUsed: Math.round(m.heapUsed / 1e6),
    heapTotal: Math.round(m.heapTotal / 1e6),
    external: Math.round(m.external / 1e6),
    arrayBuffers: Math.round(m.arrayBuffers / 1e6),
  }));
}, 30000);

Watch heapUsed after garbage collection. To force one for measurement:

node --expose-gc server.js
global.gc();
console.error("post-gc heapUsed", process.memoryUsage().heapUsed);

Two distinctions worth making before you go further.

If rss grows but heapUsed is flat, the leak is outside the JS heap: Buffers, native modules like sharp or better-sqlite3, or ArrayBuffer allocations. Heap snapshots will not show it. Check external and arrayBuffers, which are in the output above for exactly this reason.

If the process is being killed in a container, remember the cgroup counts more than your heap. A Node process with a 400MB heap can be OOMKilled at a 512MB limit because of native allocations and page cache.

Taking snapshots

From the inspector. Start with --inspect, open chrome://inspect, click Memory, take a heap snapshot. Fine in development, awkward in production because it needs a port open.

Programmatically, which is what I use in production:

import { writeHeapSnapshot } from "node:v8";

process.on("SIGUSR2", () => {
  const path = writeHeapSnapshot();
  console.error("heap snapshot written to", path);
});

Then kill -USR2 <pid> whenever you want one, with no port exposed and no restart. In Kubernetes:

kubectl exec mypod -- kill -USR2 1
kubectl cp mypod:/app/Heap.20260902.081500.1.0.heapsnapshot ./snap1.heapsnapshot

Two warnings. Writing a snapshot pauses the process for roughly the duration of a full garbage collection, which on a large heap can be seconds. And the file is about the size of your heap, so make sure the disk can take it.

Take the first snapshot after warmup, then generate steady load for twenty minutes, then take the second. Both under the same conditions, or the diff is noise.

Reading the comparison

Load snapshot one into Chrome DevTools Memory tab, then load snapshot two. Select snapshot two, switch the dropdown from Summary to Comparison, and pick snapshot one as the baseline.

Sort by Delta or Size Delta. You are looking for a constructor whose count went up and never came back down.

Typical output on a real leak:

Constructor          # New    # Deleted   Delta   Size Delta
(closure)            48,201   1,204       47k     +38 MB
Object               52,884   4,918       48k     +22 MB
(string)             61,033  12,847       48k     +14 MB
Timeout              14,402   0           14k     +2.1 MB

# Deleted of zero is the tell. Those 14,402 Timeout objects were created and never released, which means somebody is calling setTimeout or setInterval without clearing.

Expand the constructor, pick an instance, and read the Retainers panel at the bottom. That shows the chain of references keeping the object alive, from your object up to a GC root. Read it from the bottom up and look for the first thing you recognise as your code.

The retainer chain is the answer. Everything before it is finding the question.

The four causes

1. Listeners added per request

// leak: a new listener on a long-lived emitter for every request
app.get("/stream", (req, res) => {
  eventBus.on("update", (data) => res.write(JSON.stringify(data)));
});

eventBus lives for the process lifetime, and each request adds a closure that captures res. The response object, its socket, and its buffers are all retained forever.

app.get("/stream", (req, res) => {
  const onUpdate = (data) => res.write(JSON.stringify(data));
  eventBus.on("update", onUpdate);
  res.on("close", () => eventBus.off("update", onUpdate));
});

Node warns about this if you cross eleven listeners:

MaxListenersExceededWarning: Possible EventEmitter memory leak detected.
12 update listeners added to [EventEmitter].

Treat that warning as an error. It is almost always correct.

2. Unbounded caches

const cache = new Map();
function getUser(id) {
  if (!cache.has(id)) cache.set(id, expensiveLoad(id));
  return cache.get(id);
}

A cache with no eviction is a memory leak with good intentions. It works in testing because your test data has twelve users.

Use a bounded LRU:

import { LRUCache } from "lru-cache";
const cache = new LRUCache({ max: 5000, ttl: 1000 * 60 * 15 });

Same trap with arrays used as buffers, request logs kept in memory, and metric accumulators keyed by something unbounded like a URL path containing IDs. That last one is a classic: metrics[req.path] with paths like /users/8823 creates a new key per user forever.

3. Closures capturing more than you think

function makeHandler() {
  const bigData = loadTenMegabytes();
  const id = bigData.id;
  return () => console.log(id);   // captures the whole scope in some engines
}

A closure retains its entire enclosing scope in V8's implementation when any variable from it is captured. The returned function only uses id, and depending on optimisation the whole context can be retained.

Extract what you need and let the rest go out of scope:

function makeHandler() {
  const id = loadTenMegabytes().id;
  return () => console.log(id);
}

In the comparison view these show as (closure) with a large delta, which is exactly what the sample output above showed.

4. Timers never cleared

// leak: a new interval per connection, never cleared
socket.on("connect", () => {
  setInterval(() => socket.ping(), 30000);
});

Every interval holds its callback, which holds socket. Disconnected sockets stay in memory forever and the pings keep firing.

socket.on("connect", () => {
  const t = setInterval(() => socket.ping(), 30000);
  socket.on("close", () => clearInterval(t));
});

Timeout objects appearing in your comparison with zero deletions is the signature.

When snapshots show nothing

If heapUsed is flat and rss climbs, the leak is native.

Buffers. Buffer.allocUnsafe outside the JS heap. Check process.memoryUsage().external.

Native modules. sharp, canvas, grpc, better-sqlite3. Each has its own allocation patterns and its own leak bugs. Isolate by removing one at a time under load.

Fragmentation. glibc's allocator does not always return freed memory to the OS. If RSS is high but the heap is small and stable, try MALLOC_ARENA_MAX=2 or run on musl. This is more common in containers with many threads.

For native leaks, snapshots will not help. Use valgrind --tool=massif on a reproduction, or run the process under heaptrack.

Prevention

Set --max-old-space-size below your container limit. Around 75 percent. Otherwise V8 grows toward its own default of roughly 4GB and gets killed before it decides to collect.

Alert on the trend, not the threshold. A steady climb over hours is a leak. Alerting only at 90 percent means you find out during an incident.

Load test for duration, not just throughput. A ten minute load test will not show a leak that needs six hours. Run one soak test overnight before a major release.

Treat MaxListenersExceededWarning as a build failure in CI if you can. It is one of the few warnings that is nearly always a real defect.