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).
| Field | What it measures |
|---|---|
rss | Total resident set size — all memory held by the process |
heapTotal | Memory V8 reserved for the JS heap |
heapUsed | Live JS objects currently in the heap |
external | C++ objects bound to JS (e.g. Buffers) |
arrayBuffers | Memory 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
Mapor object used as a cache never evicts. Use an LRU cache or aWeakMap/WeakRefso entries can be collected. - Forgotten event listeners. Calling
emitter.on(...)on every request withoutremoveListeneraccumulates 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
setIntervalyou neverclearIntervalholds 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-signalin production so you can capture without code changes, but only on a drained instance. - Set
--max-old-space-sizedeliberately and alert on RSS so leaks surface before an OOM kill.