Memory and Leaks

What rss and heapUsed really measure, the four leak shapes behind most growth, and how to take a heap snapshot.

Syntaxv8.writeHeapSnapshot([filename[, options]]) -> string // returns the file it wrote

process.memoryUsage() returns five numbers, all in bytes: rss, heapTotal, heapUsed, external and arrayBuffers. RSS (resident set size) is the physical memory the operating system has actually handed this process: the JavaScript heap, Buffers, native allocations and the Node binary itself. heapUsed is the live JavaScript objects inside that. A memory limit imposed from outside the process is enforced against RSS, not against the heap.

Reading the two curves

heapUsed that climbs and never falls back after garbage collection is a JavaScript leak: something still references objects you are finished with. Flat heapUsed with climbing RSS points elsewhere — Buffers (external), a native dependency, or fragmentation.

The usual leak shapes

  • A module-level Map or array used as a cache, with nothing ever removed.
  • A listener added per request and never removed, as in the example below.
  • A setInterval that is never cleared, holding its closure alive.
  • A Promise that never settles, holding everything its caller was waiting with.

Snapshots

A heap snapshot is a dump of every live object and who references it. v8.writeHeapSnapshot([filename[, options]]) (added in v11.13.0) writes one and returns the path. Shorthand and longhand of the same act: call it from your own code, or start Node with --heapsnapshot-signal=SIGUSR2 and send that signal from outside. Load two snapshots taken minutes apart into Chrome DevTools and compare retained size; the constructor that grew between them is your leak.

Raise the heap limit with --max-old-space-size (in MiB). process.availableMemory() and process.constrainedMemory() became stable in v24.0.0.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Work out why a container is killed for exceeding memory every few hours.
  • Tell a real leak apart from a heap that is simply allowed to grow before collection.
  • Find which object is still referenced after a request finishes, by comparing two snapshots.
  • Decide whether a growing RSS is JavaScript objects or Buffers before changing any code.

More examples

Logging memory so growth is visible

Thirty seconds of sampling costs nothing and turns "it died again overnight" into a line you can read. The shape to look for is a sawtooth that returns to the same floor, which is healthy, against a staircase that never comes back down, which is a leak. Without this log you only learn the process was killed, not why.

Example · javascript
const mb = (bytes) => Math.round(bytes / 1024 / 1024);

const timer = setInterval(() => {
  const m = process.memoryUsage();
  console.log(JSON.stringify({
    msg: 'memory',
    rss_mb: mb(m.rss),          // what the OS limit is measured against
    heap_used_mb: mb(m.heapUsed),
    heap_total_mb: mb(m.heapTotal),
    external_mb: mb(m.external), // Buffers and other off-heap bytes
  }));
}, 30_000);
timer.unref();

// Shorthand and longhand for the same number. When RSS is all you want,
// the dedicated function skips collecting the heap figures.
process.memoryUsage().rss;   // added in v0.1.16
process.memoryUsage.rss();   // added in v15.6.0, v14.18.0

Turning an unbounded cache into a bounded one

The most common leak in a web service is a cache that was written as a Map and never given a limit. It passes every test, because tests use ten user ids. Reach for the bounded form the moment a cache key comes from user input: any cache without an eviction rule or a size cap is a leak with a delay on it.

Example · javascript
// Leaking version: every distinct key is kept until the process dies.
const cache = new Map();
function getUser(id, load) {
  if (!cache.has(id)) cache.set(id, load(id));
  return cache.get(id);
}

// Bounded version: oldest entry out when the cap is reached.
// A Map iterates in insertion order, so the first key is the oldest.
const MAX_ENTRIES = 10_000;
const bounded = new Map();

function getUserBounded(id, load) {
  if (bounded.has(id)) {
    const value = bounded.get(id);
    bounded.delete(id);          // re-insert to mark it recently used
    bounded.set(id, value);
    return value;
  }
  if (bounded.size >= MAX_ENTRIES) {
    bounded.delete(bounded.keys().next().value);
  }
  bounded.set(id, load(id));
  return bounded.get(id);
}

Taking a snapshot from a live process

Two snapshots are the whole technique: one when the process is warm, one after it has grown by a few hundred megabytes. The comparison view names the constructor that gained objects, and the retainers panel shows what is still holding them. The counter exists because the operation is expensive, and a debug endpoint that anyone can press twice a second will take the service down.

Example · javascript
// Option A: start Node with a signal handler, take nothing until asked.
//   node --heapsnapshot-signal=SIGUSR2 server.js
//   kill -USR2 <pid>     # writes Heap.<yyyymmdd>.<hhmmss>.<pid>.<tid>.<seq>.heapsnapshot

// Option B: the same act from inside the code, behind a guard.
const v8 = require('node:v8');
const path = require('node:path');

let taken = 0;
function snapshot(dir = '/tmp') {
  if (taken >= 2) throw new Error('refusing a third snapshot');
  taken++;
  // Blocks the thread until the file is written; returns its path.
  return v8.writeHeapSnapshot(path.join(dir, `heap-${taken}.heapsnapshot`));
}

// Compare two files in Chrome DevTools > Memory > load profile,
// then switch the view to "Comparison" to see what grew between them.

Discussion

  • Be the first to comment on this lesson.