Is it actually a leak? Confirming before you capture
A memory leak in Node.js is memory your program still references but will never use again. V8's garbage collector frees only unreachable objects, so a forgotten entry in a module-level Map or a listener that is never removed stays alive indefinitely. A heap snapshot is a full dump of the V8 JavaScript heap: every object, its size, and the reference chain keeping it alive. That reference chain is why snapshots are the most reliable evidence for JS heap leaks. They show what is holding memory, not just that memory is growing.
Before capturing one, confirm the growth is real. Sample process.memoryUsage() at a fixed interval under steady, representative load. Rising memory during warm-up or a traffic spike is normal. Then look at the troughs of heapUsed, not its peaks. After each major collection, a healthy service returns to a roughly flat baseline. A leaking one has a baseline that climbs. Running with --trace-gc prints a line per collection. If Mark-Compact events keep firing and the post-GC heap size still rises, V8 is trying to reclaim memory and failing, because something still references it.
| Signal | Likely cause | Heap snapshot useful? |
|---|---|---|
heapUsed baseline rises after major GCs |
JS objects retained | Yes |
external / arrayBuffers climb, heapUsed flat |
Buffer or ArrayBuffer backing stores | Partially: shows the JS wrappers, not the off-heap bytes |
rss climbs, all other fields flat |
Native addon, allocator fragmentation | No |
If your data matches the second or third row, a heap snapshot will mostly show small wrapper objects or nothing relevant.
// memory-sampler.js: preload it so the service code stays unchanged:
// node --trace-gc -r ./memory-sampler.js server.js
// --trace-gc prints one line per collection; compare Mark-Compact lines with the troughs below.
const INTERVAL_MS = 10_000; // sample every 10 s under steady load
const WINDOW = 30; // 30 samples = one 5-minute window
const toMB = (n) => Math.round(n / 1024 / 1024);
let window = [];
let previousTrough = null;
const timer = setInterval(() => {
const m = process.memoryUsage();
window.push(m.heapUsed);
// Log every field so you can tell which row of the table you are in.
console.log(JSON.stringify({
t: new Date().toISOString(),
rssMB: toMB(m.rss),
heapUsedMB: toMB(m.heapUsed),
heapTotalMB: toMB(m.heapTotal),
externalMB: toMB(m.external),
arrayBuffersMB: toMB(m.arrayBuffers),
}));
if (window.length === WINDOW) {
// The lowest heapUsed in a window approximates the post-GC baseline.
// Peaks are noise. A trough that rises window after window points to retained JS objects.
const trough = Math.min(...window);
if (previousTrough !== null) {
console.log(JSON.stringify({
event: 'heap-trough',
troughMB: toMB(trough),
deltaMB: toMB(trough - previousTrough),
}));
}
previousTrough = trough;
window = [];
}
}, INTERVAL_MS);
timer.unref(); // sampling should never keep the process alive
A preloaded sampler that logs every process.memoryUsage() field and reports the heapUsed trough per five-minute window, so you can see whether the post-GC baseline climbs while running under --trace-gc.
Ways to capture a heap snapshot and when to use each
All capture methods produce the same .heapsnapshot file, which you load into the Chrome DevTools Memory panel. They differ in how you trigger the capture and what you expose to do it. Every method pauses the main thread while V8 walks the heap, and the process can need roughly twice its current heap size in memory during the write. A capture is therefore a disruptive event, and the trigger mechanism decides who can cause one.
| Method | Code changes | Production suitability | Main risk |
|---|---|---|---|
v8.writeHeapSnapshot() behind an authenticated admin endpoint or internal signal handler |
Small: a handler plus access control | Good | An unprotected endpoint becomes a denial-of-service lever |
--heapsnapshot-signal |
None, only a startup flag | Good | Anyone with signal rights on the host can stall the process |
--heapsnapshot-near-heap-limit |
None, only a startup flag | Good as a safety net | Capture happens at peak memory, so it may itself trigger the OOM kill |
--inspect with Chrome DevTools |
None | Non-public environments only | The inspector port allows arbitrary code execution |
v8.writeHeapSnapshot() gives you the most control. You can name files, rate-limit captures, and drain traffic from the instance before writing. --heapsnapshot-signal suits processes you can't redeploy quickly. --heapsnapshot-near-heap-limit records the moment you usually miss, just before the crash. Make sure the container has spare memory and a writable volume, or the snapshot will be lost when the process dies.
The heapdump npm module still shows up in older codebases. The built-in APIs now cover what it did without requiring a native dependency, so new services don't need it.
// heap-snapshot.js: load once at startup, e.g. require('./heap-snapshot') in your entry file.
// Captures a snapshot on SIGUSR2 from inside the host, without exposing an HTTP endpoint.
// Avoid SIGUSR1: Node reserves it for activating the inspector.
const v8 = require('node:v8');
const path = require('node:path');
// Point this at a writable volume that outlives the container.
const SNAPSHOT_DIR = process.env.HEAP_SNAPSHOT_DIR || '/var/snapshots';
// Each capture pauses the main thread and can roughly double memory use, so allow at most one per window.
const MIN_INTERVAL_MS = 10 * 60 * 1000;
let lastCaptureAt = 0;
process.on('SIGUSR2', () => {
const now = Date.now();
if (now - lastCaptureAt < MIN_INTERVAL_MS) {
console.warn('heap snapshot skipped: rate limit active');
return;
}
lastCaptureAt = now;
// Put the pid and a timestamp in the name so snapshots from different instances don't overwrite each other.
const file = path.join(
SNAPSHOT_DIR,
`heap-${process.pid}-${new Date(now).toISOString().replace(/[:.]/g, '-')}.heapsnapshot`
);
try {
// Synchronous call: the event loop stops until the file is fully written.
const written = v8.writeHeapSnapshot(file);
console.info(`heap snapshot written: ${written}`);
} catch (err) {
console.error('heap snapshot failed', err);
}
});
// Trigger from the host: kill -USR2 <pid>
// Flag-only alternatives that need no code changes:
// node --heapsnapshot-signal=SIGUSR2 server.js
// node --heapsnapshot-near-heap-limit=1 --diagnostic-dir=/var/snapshots server.js
A rate-limited SIGUSR2 handler calls v8.writeHeapSnapshot() and writes uniquely named files to a persistent directory, and the trailing comments show the startup flags that need no code changes.
Capturing safely in production
A heap snapshot has a real operational cost. V8 serializes the entire heap synchronously, so the event loop is blocked for the full write. That can take seconds on a small heap and much longer on a large one. During the pause the process answers nothing, including health checks. If a liveness probe times out, the orchestrator restarts the container mid-write. The write can also temporarily need memory comparable to the heap itself. On a container already near its limit, that extra memory triggers an OOM kill. Either way, you lose the snapshot and the leaking process along with it.
A safe procedure
- Pick one instance. Use a canary or a single replica that shows the growth pattern, never the whole fleet.
- Take it out of rotation. Drain it or remove it from the load balancer so no user requests stall behind the pause. Relax its liveness probe so the freeze isn't treated as a hang.
- Check headroom. Compare current heap usage against the container memory limit, and confirm free disk at least as large as the heap. If memory is tight, capture earlier in the leak's growth rather than near the limit.
- Write to a durable volume. Choose a location you can copy off the host. The container's ephemeral filesystem disappears on restart.
- Retrieve the file and confirm it is complete before you recycle the instance.
Treat snapshots as sensitive data
A snapshot contains every string in memory, including auth tokens, session cookies, personal data, request bodies and loaded secrets. Restrict who can trigger a capture. Store snapshots encrypted, with access limited to the people investigating. Delete them once the investigation is closed.
+---------------------------------------------------------+
| Load balancer |
+---------------------------------------------------------+
| | :
v v : drained, no traffic
+---------+ +---------+ +-------------------------------+
| Replica | | Replica | | Canary replica |
| A | | B | | - liveness probe relaxed |
+---------+ +---------+ | - heap vs. container limit OK |
| - free disk >= heap size |
+-------------------------------+
|
| heap snapshot write:
| event loop blocked,
| extra memory ~ heap size
v
+-------------------------------+
| Durable volume |
| (not ephemeral container fs) |
+-------------------------------+
|
| copy off host, verify complete
v
+-------------------------------+
| Restricted, encrypted storage |
| - investigators only |
| - delete when case closes |
+-------------------------------+
Safe production capture: drain a single canary from the load balancer, relax its liveness probe and check memory and disk headroom, write the snapshot to a durable volume, then copy it to restricted, encrypted storage and delete it when the investigation closes.
How the three-snapshot technique works
A single snapshot shows what is on the heap, not what is growing. A process that has been running for a while holds route tables, compiled templates, connection pools, warmed caches and modules that only initialized on their first request. All of these can be large and long-lived, and all of them look like leaks when viewed alone. A leak only shows up as change over time, so you need several snapshots taken under controlled conditions.
The procedure
- Warm up. Send traffic until lazy initialization and caches have settled, then take the baseline snapshot. V8 runs a full garbage collection before writing a snapshot, so anything still present is reachable.
- Apply a fixed load. Send a fixed number of requests, such as 1,000 identical calls to one endpoint. Then take the second snapshot.
- Repeat the identical load. Take the third snapshot.
In Chrome DevTools, open the third snapshot and switch the view to Comparison against the second. Then compare the second against the first. Sort by # Delta. You are looking for constructors whose count grows by roughly the same amount in each round, because that matches your request count. An object that is large but stable is a cache. An object that grows by about 1,000 per 1,000 requests is a leak.
Practice setup
The accompanying example is a small HTTP server that pushes each request's headers and a buffer into a module-level array, which is never cleared. It also includes a load script that sends a fixed batch of requests in a loop. Run three rounds and the Comparison view will show the leaked arrays and objects growing by the batch size in each round.
// leak.js (Node 18+ for global fetch)
// Terminal 1: node leak.js server Terminal 2: node leak.js load
const http = require('node:http');
const v8 = require('node:v8');
const PORT = 3000;
const BATCH = 1000; // identical load per round, so the leak grows by ~BATCH per round
const leaked = []; // the bug: module-level array that is never cleared
if (process.argv[2] === 'server') {
http.createServer((req, res) => {
if (req.url === '/snapshot') {
// Forces a full GC, then writes a .heapsnapshot file to the server's cwd (blocks while writing)
const file = v8.writeHeapSnapshot();
return res.end(`${file}\n`);
}
// Every request retains its headers object plus a 1 KiB Buffer
leaked.push({ headers: req.headers, payload: Buffer.alloc(1024) });
res.end('ok\n');
}).listen(PORT, () => console.log(`pid ${process.pid} listening on :${PORT}`));
} else {
const base = `
// Requests are sequential so each round is exactly BATCH requests
const send = async (n) => {
for (let i = 0; i < n; i++) await (await fetch(`${base}/leak`)).text();
};
const snap = async (label) => {
const file = (await (await fetch(`${base}/snapshot`)).text()).trim();
console.log(`${label}: ${file}`);
};
(async () => {
await send(200); // warm-up: let lazy init and caches settle
await snap('baseline'); // snapshot 1
await send(BATCH);
await snap('round 1'); // snapshot 2
await send(BATCH);
await snap('round 2'); // snapshot 3: compare 3 vs 2 and 2 vs 1, sort by # Delta
})();
}
A self-contained leaky HTTP server and load script that writes three heap snapshots with v8.writeHeapSnapshot(), using a warm-up followed by two identical batches so the leaked objects grow by about the batch size between snapshots in the Comparison view.
Reading Chrome DevTools: retained size, retainers and common leak patterns
Every object in a snapshot has two sizes. Shallow size is the memory the object itself occupies. Retained size is what the garbage collector would free if that object became unreachable, including everything reachable only through it. A small Map can have a tiny shallow size and a huge retained size, which is why you sort by retained size when hunting leaks.
The Summary view groups objects by constructor name. Open the group whose count or retained size grew between snapshots, select an instance, and read the Retainers panel from the bottom up. Each row is a reference keeping the object alive. Follow the chain until you reach something long-lived, such as a module-level Map, a closure context, or an emitter's _events array. That anchor is usually where the fix goes.
| Pattern | What it looks like in the snapshot | Usual fix |
|---|---|---|
| Unbounded cache or Map keyed by request/user ID | Map with growing entry count and large retained size |
Bounded LRU, TTL, or WeakMap |
| Listener added per request | Long _events arrays, MaxListenersExceededWarning in logs |
Remove the listener or use once |
Closure capturing req/res |
IncomingMessage instances retained via context |
Capture only the fields you need |
| Uncleared timers/intervals | Accumulating Timeout objects |
clearTimeout/clearInterval on teardown |
| Promises that never settle | Pending Promise objects holding callbacks |
Add timeouts and make sure every path resolves or rejects |
| Per-request data on long-lived objects | Request data hanging off a singleton | Scope the data to the request |
Bundled or minified code gives you constructor names like a or t. Where possible, take snapshots from an unminified build. Large snapshots also need a workstation with plenty of RAM, because DevTools uses considerably more memory than the file size while it parses them.
Validating the fix and preventing regressions
A fix is only confirmed when the same experiment that exposed the leak stops reproducing it. Rebuild with the patch, then drive the same load profile for the same duration: same endpoints, request mix and concurrency. Take three snapshots at the same points as before and run the same comparison. Changing the workload while validating makes the result meaningless, because a lighter or different load can hide a leak that is still there.
In the comparison view, look at the constructors that grew before. After a fix, their counts should rise during warm-up and then level off, and the delta between the second and third snapshot should be near zero. A smaller but steady positive delta usually means you fixed one retention path and another remains.
To catch regressions after deployment, track the trend rather than waiting for the hard limit. A process that reaches the ceiling set by --max-old-space-size or its container memory limit gets killed, and by then you have lost the state you needed to diagnose it. Instead, sample process.memoryUsage() on a fixed interval, for example every 30 seconds, using a timer with unref() so it doesn't keep the process alive. Export heapUsed, heapTotal, rss and external as gauges to your metrics system. Alert when the minimum heapUsed over a rolling window keeps rising, since the floor after garbage collection reflects retained memory better than the peaks do. Set this alert well below the hard limit so you have time to take snapshots before the process is restarted.
When to avoid heap snapshots, alternatives, and impact on engineering time
Heap snapshots suit backend and platform engineers who own long-running Node.js services and can see that the post-GC heap floor is climbing. Skip them in these cases:
- The process is already near its container memory limit. Writing the snapshot needs extra memory and can push the process into an OOM kill.
- RSS climbs while the JS heap stays flat. This points to native addons, Buffers or fragmentation, and a snapshot of the JS heap will not show the cause.
- The heap is so large that the pause is unacceptable. The process is frozen while the snapshot is written.
- The capture can be triggered over the network. An exposed endpoint or signal handler lets anyone stall the service or dump its memory.
| Tool | Overhead | Answers |
|---|---|---|
| RSS and heap metrics trends | Negligible | Is memory growing, and is it heap or native? |
--trace-gc |
Low | Does GC recover memory, or does the floor keep rising? |
| Sampling heap profiler | Low | Which call sites allocate the most? |
| Allocation timeline | Moderate | Which allocations survive over time? |
| Heap snapshot | Full pause plus extra memory | What retains each object? |
Setting this up takes real effort: a safe trigger, storage for the files, and practice reading retainer chains. Captures in production carry risk, but they are often necessary because staging rarely reproduces real traffic. A compare-don't-stare workflow keeps analysis short because you diff snapshots instead of browsing them.
Illustrative estimate (assumed numbers, not a benchmark):
Hours saved = (OOM incidents per quarter × hours per incident) − investigation hours
With 6 incidents × 3 hours − 10 hours of investigation, this saves 8 hours in the first quarter. After that, the leak no longer recurs, so the savings continue each quarter.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to node.js memory leak heap snapshot, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.