> ## Content Index
> Fetch the complete content index at: https://debugly.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# Finding a Node.js Memory Leak With Heap Snapshots
- URL: https://debugly.dev/nodejs-memory-leak-heap-snapshot/
- Published: 2025-09-02T08:55:00.000Z
- Updated: 2026-10-23T09:13:12.000Z
- Description: Memory climbs, the process gets killed, and restarting fixes it for six hours. Here is the diff based method that finds the retainer.
- Author: Rohit Bhadani
- Tags: Node.js, Debugging, Performance, JavaScript

## The short version

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.

```bash
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.

```javascript
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:

```bash
node --expose-gc server.js

```

```javascript
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](https://debugly.dev/oomkilled-container-memory-limit-debugging/). 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:

```javascript
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:

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

```javascript
// 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.

```javascript
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

```javascript
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:

```javascript
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

```javascript
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:

```javascript
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

```javascript
// 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.

```javascript
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.

---

Related reading: [The Dashboard That Got Slower the Longer You Left It Open](https://debugly.dev/browser-memory-leak-detached-dom/) explores a connected angle.

Published on [debugly.dev](https://debugly.dev/) by [Rohit Bhadani](https://debugly.dev/about/) | Founder of [Krova Cloud](https://krova.cloud/?ref=debugly.dev)