Find and fix Node.js memory leaks with heap snapshots
A step-by-step method for Node.js memory leaks: confirm the growth, capture heap snapshots safely, compare them in DevTools and fix the five usual causes.
Updated 7 min read

In this article
A Node.js memory leak shows up as memory that keeps rising across hours of steady traffic and never falls back after garbage collection, until the process dies with "JavaScript heap out of memory" or the container is killed. The reliable fix is the same every time: confirm the growth with metrics, take two heap snapshots a few minutes apart, compare them in Chrome DevTools, and follow the retainers of whatever grew to the line of code that holds on to it.
This troubleshooting guide walks through that process with a real leak, the built-in snapshot options in Node.js 24, and the five causes behind most leaks in Express-style APIs. All commands and outputs below come from Node.js 24.19.0.
Step 1: confirm it is a leak, not just high memory
High memory use is not a leak. A process that grows to 400 MB at start-up and stays there is fine. A leak is memory that keeps rising under a stable workload. Log process.memoryUsage() regularly and watch the trend:
| Field | What it measures | Leak signal |
|---|---|---|
heapUsed | Memory used by JavaScript objects | Rising floor after each GC: a leak in your JavaScript |
heapTotal | Heap V8 has reserved | Follows heapUsed upwards |
external and arrayBuffers | Memory outside the JS heap, such as Buffer contents | Rising: buffers or native resources retained |
rss | Total memory the OS gives the process | What container limits measure; noisy on its own |
Here is a deliberately leaky server. It caches every URL it has ever seen in a Map that nothing ever trims:
// leak.mjs
import { createServer } from 'node:http';
const cache = new Map(); // grows forever: one entry per unique URL
createServer((req, res) => {
if (!cache.has(req.url)) {
cache.set(req.url, { body: 'x'.repeat(10_000), at: Date.now() });
}
res.end(cache.get(req.url).body.slice(0, 10));
}).listen(3000);
setInterval(() => {
const { heapUsed, rss } = process.memoryUsage();
console.log(`heapUsed ${(heapUsed / 1048576).toFixed(1)} MiB, rss ${(rss / 1048576).toFixed(1)} MiB, entries ${cache.size}`);
}, 5000).unref();Sending 20,000 requests to distinct URLs produced this log:
heapUsed 41.1 MiB, rss 112.2 MiB, entries 2846
heapUsed 87.1 MiB, rss 148.3 MiB, entries 6495
heapUsed 118.4 MiB, rss 224.9 MiB, entries 10164
heapUsed 144.5 MiB, rss 48.5 MiB, entries 13474
heapUsed 173.2 MiB, rss 139.6 MiB, entries 16811
heapUsed 211.9 MiB, rss 125.7 MiB, entries 20000Notice that rss jumps around while heapUsed climbs steadily with the number of entries. That steady climb is the signature of a JavaScript leak. In production, send these values to your metrics system rather than the console and look at days, not minutes.

Step 2: capture heap snapshots
A heap snapshot is a complete record of every object on the JavaScript heap and what references it. Node.js 24 gives you four ways to take one.
From a signal, without changing code
node --heapsnapshot-signal=SIGUSR2 --diagnostic-dir=./diag server.js
# later, from another shell
kill -USR2 <pid>The snapshot is written as Heap.<date>.<time>.<pid>.0.001.heapsnapshot in the --diagnostic-dir directory, which defaults to the working directory.
From code
import { writeHeapSnapshot, getHeapStatistics } from 'node:v8';
const file = writeHeapSnapshot(); // blocks the thread while it writes
const { used_heap_size, heap_size_limit } = getHeapStatistics();
console.log(file);
console.log(`used ${(used_heap_size / 1048576).toFixed(1)} MiB of ${(heap_size_limit / 1048576).toFixed(0)} MiB`);The official guide suggests triggering this from an admin HTTP route or a signal handler, and warns not to expose such an endpoint publicly.
Automatically, just before the crash
For leaks that only appear after days, let Node.js write a snapshot as the heap approaches its limit. --heapsnapshot-near-heap-limit is no longer experimental as of Node.js 24.13.1:
$ node --max-old-space-size=64 --heapsnapshot-near-heap-limit=1 --diagnostic-dir=./diag oom.mjs
Wrote snapshot to ./diag/Heap.20261009.231512.74163.0.001.heapsnapshot
...
FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memoryInteractively
Start the process with --inspect, open chrome://inspect in Chrome, and use the Memory tab. This is the most convenient option locally; avoid exposing the inspector port on a server.
Snapshots are expensive. The official guide warns that taking one stops all other work on the main thread, can take more than a minute on a large heap, and is built in memory, so it can double heap usage and crash the process. In production, take snapshots from one instance that can be removed from the load balancer.
Step 3: compare two snapshots
A single snapshot shows what is big. Two snapshots show what is growing, which is what you need. The process from the Node.js diagnostics guide:
- Start the app and let it finish start-up.
- Exercise the feature you suspect, then take the first snapshot.
- Keep exercising only that feature for a while.
- Take the second snapshot.
- In Chrome DevTools, open the Memory tab and load both files, older first.
- Select the newer snapshot and switch the view from Summary to Comparison.
- Sort by size delta and inspect the Retainers panel for the largest positive entries.
For the leaky server above, the comparison is dominated by new strings and objects, and their retainer path leads through a Map to the cache variable in leak.mjs. That retainer chain, not the object type, tells you which code to fix.

The five usual suspects
1. Unbounded caches and maps
Any Map, object or array that gains entries per request or per user and is never trimmed will leak. Give every cache a maximum size or an expiry. A minimal least-recently-used cache needs only a Map, because maps remember insertion order:
class BoundedCache {
#map = new Map();
constructor(max) { this.max = max; }
get(key) {
const value = this.#map.get(key);
if (value !== undefined) { // refresh recency
this.#map.delete(key);
this.#map.set(key, value);
}
return value;
}
set(key, value) {
this.#map.delete(key);
this.#map.set(key, value);
if (this.#map.size > this.max) {
this.#map.delete(this.#map.keys().next().value); // evict oldest
}
}
get size() { return this.#map.size; }
}
const cache = new BoundedCache(1000);
for (let i = 0; i < 20_000; i++) cache.set(`/item/${i}`, 'x'.repeat(10_000));
console.log(cache.size); // 1000When the key is an object whose lifetime you do not control, such as a request or a socket, use a WeakMap so the entry disappears when the key is collected.
2. Event listeners added per request
Adding a listener to a long-lived emitter inside a request handler keeps every handler, and everything its closure references, alive. Node.js warns you once an emitter passes ten listeners for one event:
$ node listeners.mjs
(node:74819) MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 config-changed listeners added to [EventEmitter]. MaxListeners is 10. Use emitter.setMaxListeners() to increase limitTreat that warning as a bug report, not noise. Remove listeners with off() when the request ends, or use once(). Raising the limit with setMaxListeners() only hides the leak.
3. Timers that are never cleared
A setInterval() started per connection or per job keeps its callback and closure alive until clearInterval() is called.
4. Closures that capture large objects
A small callback stored somewhere long-lived can keep a whole request body or database result alive because it references a variable in the same scope. Copy out only the fields you need before storing a callback.
5. Module-level arrays used as queues or logs
Arrays at module scope that collect metrics, errors or jobs "for later" grow forever if nothing drains them. Bound them or send the data somewhere else.
Gotchas
- Raising the heap limit is not a fix.
--max-old-space-size(in MiB) delays the crash. Use it to fit the heap to the container, not to hide growth. Note thatheap_size_limitreports more than the old-space value: with--max-old-space-size=512we saw 704 MiB. - Container kills do not produce a JavaScript stack trace. If the container limit is lower than the V8 heap limit, the OS kills the process first. Keep the heap limit below the container limit so you get the Node.js error and the near-limit snapshot.
- Snapshots contain your data. They include strings from memory: tokens, personal data, secrets. Store and share them as carefully as a database dump.
- Growing
externalmemory will not show up as large JavaScript objects. Look for retainedBuffers, streams that are never consumed and native resources. - For allocation hot spots, rather than retained objects, use the sampling heap profiler:
node --heap-prof, stable since Node.js 22.4.0, writes a profile on exit that DevTools can open.
What to do next
Add a memory dashboard before you need it: heapUsed, external and rss per instance over at least a week. Start production processes with --heapsnapshot-near-heap-limit=1 and a --diagnostic-dir on a volume that survives restarts, so the next out-of-memory crash leaves evidence behind. Then practise the comparison workflow once on a leak you planted yourself, like the one above, so the first real incident is not the first time you open the Comparison view.
Frequently asked questions
How do I know if my Node.js app has a memory leak?
Plot
process.memoryUsage().heapUsedover time under steady traffic. If the low points after garbage collection keep rising for hours, you have a leak. A high but flat line is not a leak.What does "JavaScript heap out of memory" mean?
V8 reached its heap size limit and could not free enough memory, so Node.js aborted. It is caused either by a leak or by a workload that genuinely needs more heap than the limit allows.
Is it safe to take a heap snapshot in production?
With care. Taking a snapshot pauses the main thread, can take over a minute, and can double heap usage. Take it on one instance removed from the load balancer, and treat the file as sensitive data.
Should I just increase --max-old-space-size?
Only to fit the heap to the memory you actually have. For a real leak it only postpones the crash. Keep the limit below your container's memory limit.
What does MaxListenersExceededWarning mean?
More than ten listeners were added for one event on one emitter, which usually means a listener is added per request and never removed. Remove listeners when the work ends instead of raising the limit.
Which tool opens .heapsnapshot files?
Chrome or any Chromium-based browser: open DevTools, go to the Memory tab and load the file. Load two and use the Comparison view to see what grew.
Your next customer could start right here.
Introduce your tool, product or business to readers exploring technology.
Explore placements and request a private quote
Sources
- Using heap snapshot — Node.js
- Command-line options (Node.js v24 docs) — Node.js
- V8 module: writeHeapSnapshot (Node.js v24 docs) — Node.js
- Process: memoryUsage (Node.js v24 docs) — Node.js
Written with substantial AI assistance. Facts checked against cited sources. Editorial policy.
Comments
No comments yet. Be the first to join the conversation.
Leave a comment
Every comment is read by a moderator before it appears. Your email address is never published; it is used only to contact you about this comment.
More from Cloudsoft Zone

TutorialNode.js
How to test a Node.js API with the built-in test runner (node:test)
Write, run, mock and measure coverage for a small Node.js HTTP API using only node:test and node:assert. Covers port-0 servers, fetch(), spies, t.mock.method(), fake dates and coverage thresholds.
By Cloudsoft Zone

TutorialNode.js
Node.js streams in practice: pipeline(), backpressure and async generators
Process files of any size in Node.js with flat memory use. A hands-on guide to stream/promises pipeline(), backpressure, async generator transforms, AbortSignal timeouts and streaming downloads over HTTP.
By Cloudsoft Zone

Industry updateNode.js
Which Node.js version to use in October 2026: LTS lines and the new schedule
Node.js 26 becomes LTS on 28 October 2026, Node.js 24 moves to maintenance and 20.x is already end of life. Here is every supported line, the new yearly release model from Node.js 27, and which version to run.
By Cloudsoft Zone
