Skip to main content

Cloudsoft Zone Build, deploy and scale with practical guides to PHP, Node.js, React, DevOps and AI automation. Explore the guides

Cloudsoft Zone
Node.jsDevOpsJavaScriptTroubleshooting guide

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.

Cloudsoft Zone

Updated 7 min read

Close-up of a rusted, flaking metal water pipe with a drop of water forming at a corroded spot, green leaves blurred behind
Photograph: Shamrock Lee / CC BY 4.0, via Wikimedia Commons
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:

FieldWhat it measuresLeak signal
heapUsedMemory used by JavaScript objectsRising floor after each GC: a leak in your JavaScript
heapTotalHeap V8 has reservedFollows heapUsed upwards
external and arrayBuffersMemory outside the JS heap, such as Buffer contentsRising: buffers or native resources retained
rssTotal memory the OS gives the processWhat 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:

JavaScript
// 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:

Shell
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 20000

Notice 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.

Water spilling from a hillside into a roadside ditch beside a narrow road through a green Highland glen under a clear sky
You usually notice a leak by its effects first. Metrics tell you memory is growing; snapshots tell you where it comes from.

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

Shell
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

JavaScript
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:

Shell
$ 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 memory

Interactively

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:

  1. Start the app and let it finish start-up.
  2. Exercise the feature you suspect, then take the first snapshot.
  3. Keep exercising only that feature for a while.
  4. Take the second snapshot.
  5. In Chrome DevTools, open the Memory tab and load both files, older first.
  6. Select the newer snapshot and switch the view from Summary to Comparison.
  7. 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.

Two black Corsair Vengeance LPX DDR4 memory modules lying side by side on a dark textured surface
More RAM or a larger heap only delays an out-of-memory crash; the retainer path in a snapshot shows what 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:

JavaScript
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); // 1000

When 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:

Shell
$ 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 limit

Treat 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 that heap_size_limit reports more than the old-space value: with --max-old-space-size=512 we 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 external memory will not show up as large JavaScript objects. Look for retained Buffers, 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().heapUsed over 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.

Advertise on CloudSoftZone

Your next customer could start right here.

Introduce your tool, product or business to readers exploring technology.

Advertise with us

Explore placements and request a private quote

Sources

  1. Using heap snapshot — Node.js
  2. Command-line options (Node.js v24 docs) — Node.js
  3. V8 module: writeHeapSnapshot (Node.js v24 docs) — Node.js
  4. 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.

Never published. Used only to contact you about this comment.

Loading the security check…

Comments are moderated before publication.