Skip to content
Node.js nd debugging 4 min read

Finding Memory Leaks & Heap Snapshots

A memory leak in Node.js happens when objects that are no longer needed stay reachable from the garbage collector’s roots, so the heap grows steadily until the process slows down or crashes with an out-of-memory error. Because V8 reclaims memory automatically, leaks are almost always logical: a reference you forgot to release rather than a missing free(). The most reliable way to find them is to capture heap snapshots, compare them over time, and inspect which objects are growing. This page shows how to capture snapshots programmatically and on demand, analyze them in Chrome DevTools, recognize the usual culprits, and watch memory in production.

Watching memory with process.memoryUsage

Before reaching for snapshots, confirm a leak actually exists. process.memoryUsage() returns the current memory footprint and is cheap enough to log on an interval.

function logMemory() {
  const { rss, heapTotal, heapUsed, external, arrayBuffers } =
    process.memoryUsage();
  const mb = (n) => (n / 1024 / 1024).toFixed(1);
  console.log(
    `rss=${mb(rss)}MB heapTotal=${mb(heapTotal)}MB ` +
      `heapUsed=${mb(heapUsed)}MB external=${mb(external)}MB ` +
      `arrayBuffers=${mb(arrayBuffers)}MB`,
  );
}

setInterval(logMemory, 5000).unref();

Output:

rss=58.3MB heapTotal=22.1MB heapUsed=15.4MB external=1.2MB arrayBuffers=0.1MB
rss=71.9MB heapTotal=29.6MB heapUsed=23.8MB external=1.2MB arrayBuffers=0.1MB
rss=88.4MB heapTotal=38.4MB heapUsed=32.7MB external=1.2MB arrayBuffers=0.1MB

If heapUsed keeps climbing across requests and never falls back after garbage collection, you have a leak in the JavaScript heap. A growing external or arrayBuffers instead points at native memory (Buffers, TypedArrays, or addons).

FieldWhat it measures
rssTotal resident set size — all memory held by the process
heapTotalMemory V8 reserved for the JS heap
heapUsedLive JS objects currently in the heap
externalC++ objects bound to JS (e.g. Buffers)
arrayBuffersMemory for ArrayBuffer and SharedArrayBuffer

Capturing heap snapshots

A heap snapshot is a complete graph of every object in the JS heap at a moment in time. The built-in node:v8 module writes one to disk with v8.writeHeapSnapshot().

import v8 from "node:v8";

// Returns the filename it wrote; pass a path to control it.
const file = v8.writeHeapSnapshot(`./heap-${Date.now()}.heapsnapshot`);
console.log(`Snapshot written to ${file}`);

The CommonJS form is identical with const v8 = require("node:v8"). You can also stream a snapshot with v8.getHeapSnapshot(), which returns a Readable — handy for piping over the network or into a custom store.

To diagnose a leak, capture at least two snapshots with the suspected work happening between them, then compare. Trigger garbage collection first so transient allocations don’t pollute the diff. Run with --expose-gc to make global.gc() available.

node --expose-gc server.js
import v8 from "node:v8";

async function snapshotAround(label, work) {
  global.gc(); // settle the heap
  v8.writeHeapSnapshot(`./${label}-before.heapsnapshot`);
  await work();
  global.gc();
  v8.writeHeapSnapshot(`./${label}-after.heapsnapshot`);
}

Capturing on a signal

In production you rarely want snapshot code baked into a hot path. Node can dump a snapshot when it receives a signal, so you can grab one from a running process without redeploying.

node --heapsnapshot-signal=SIGUSR2 server.js

# In another terminal, ask the live process for a snapshot:
kill -SIGUSR2 <pid>

Output:

Wrote snapshot to /app/Heap.20260614.142233.12.0.001.heapsnapshot

Snapshotting pauses the event loop and can take seconds on a multi-gigabyte heap. Capture during a maintenance window or on a single drained instance, never across an entire fleet at once.

Analyzing snapshots in DevTools

Open Chrome (or Edge), navigate to chrome://inspect, and click Open dedicated DevTools for Node. On the Memory tab, choose Load and select your .heapsnapshot file. Load both the before and after files, then switch the dropdown from Summary to Comparison and pick the earlier snapshot as the baseline.

The comparison view sorts by Delta — the net change in object count. A constructor with a large positive delta that grows every cycle is your leak. Click it, then follow the Retainers panel at the bottom: it shows the chain of references keeping each object alive, all the way back to a GC root. That chain tells you exactly which closure, array, or map is holding on.

Common leak sources

Most leaks fall into a few patterns:

  • Unbounded caches. A plain Map or object used as a cache never evicts. Use an LRU cache or a WeakMap/WeakRef so entries can be collected.
  • Forgotten event listeners. Calling emitter.on(...) on every request without removeListener accumulates handlers. Node warns at 10 listeners.
  • Closures capturing large scopes. A callback that closes over a big buffer keeps it alive for as long as the callback is referenced.
  • Timers and intervals. A setInterval you never clearInterval holds its closure forever. Call .unref() if it shouldn’t keep the process alive.
  • Module-level arrays. Pushing onto a const log = [] declared at module scope grows without bound.
import { EventEmitter } from "node:events";

const bus = new EventEmitter();

// LEAK: a new listener per request, never removed.
function handle(req) {
  bus.on("tick", () => process.stdout.write(req.id));
}

// FIX: register once, or remove when done.
function handleFixed(req) {
  const onTick = () => process.stdout.write(req.id);
  bus.once("tick", onTick); // auto-removes after firing
}

Best practices

  • Confirm the leak with process.memoryUsage() trends before spending time on snapshots.
  • Always global.gc() (under --expose-gc) before capturing so you diff only retained objects.
  • Take at least two snapshots and use DevTools Comparison mode, not a single snapshot.
  • Follow the Retainers chain to the root — that is where the bug lives, not where the memory is allocated.
  • Prefer WeakMap, WeakRef, and bounded LRU caches over plain objects for anything keyed by long-lived references.
  • Use --heapsnapshot-signal in production so you can capture without code changes, but only on a drained instance.
  • Set --max-old-space-size deliberately and alert on RSS so leaks surface before an OOM kill.
Last updated June 14, 2026
Was this helpful?