Memory & Leaks

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Node.js 26 (26.10.0, released 2026-09-21, the Current line that becomes LTS on 2026-10-28), with the differences of Node.js 24 "Krypton" (24.21.0, Active LTS, V8 13.6) and Node.js 22 "Jod" (22.23.3, Maintenance, V8 12.4) called out; Node.js 26 ships V8 14.6. Versions and stability levels were checked against the official documentation on 2026-10-04, and every example below was run on all three lines unless the text says otherwise. JavaScript is a garbage-collected language, so you never free memory by hand, but you can still retain it: anything reachable from a long-lived object (a module variable, a timer, an event emitter, a cache) stays alive. This page covers how V8 organises and collects the heap, how to measure and size it, and the three diagnostic tools the official guides describe (garbage collection traces, heap snapshots and the heap profiler), then the leak patterns you will meet most often. The running scenario is the Bookshelf application that grows across this section, described in Introduction & Architecture; the tools here pair with Profiling & Performance (CPU and event-loop questions), Debugging (the inspector and DevTools) and Diagnostic Reports, Logging & Observability (reports written when the process runs out of memory).

The stability and version map

Table 1. APIs and flags covered on this page
API or flag Stability (Node.js 26 docs) Version notes

process.memoryUsage(), process.memoryUsage.rss()

No stability index shown (long-standing)

external added in v7.2.0, arrayBuffers in v13.9.0 (also v12.17.0); rss() added in v15.6.0.

process.constrainedMemory(), process.availableMemory()

Stable

Stable since v24.0.0 and v22.16.0; before that Experimental. availableMemory() was added in v22.0.0 (also v20.13.0), constrainedMemory() in v19.6.0 (also v18.15.0).

v8.getHeapStatistics(), v8.getHeapSpaceStatistics(), v8.getHeapCodeStatistics()

No stability index shown; the node:v8 page has no module-wide index

Fields of getHeapStatistics() have grown over time (for example number_of_detached_contexts, total_allocated_bytes); the heap space list and its order are explicitly not guaranteed.

v8.writeHeapSnapshot(), v8.getHeapSnapshot()

No stability index shown

Added in v11.13.0. Options exposeInternals and exposeNumericValues since v19.1.0; failures throw since v18.0.0.

v8.setHeapSnapshotNearHeapLimit()

Stable

Added in v18.10.0 (also v16.18.0); marked stable in v24.0.0 and v22.17.0.

v8.queryObjects()

No stability index shown (no longer experimental)

Added in v22.0.0 (also v20.13.0); "no longer experimental" in v25.4.0 and v24.13.1. A runtime ExperimentalWarning was still printed on 22.23.3, 24.21.0 and 26.10.0 (observed).

v8.GCProfiler

No stability index shown

Added in v19.6.0 (also v18.15.0); [Symbol.dispose]() since v25.5.0 and v24.13.1.

v8.startHeapProfile() and its SyncHeapProfileHandle

1 Experimental (the handle class)

Added in v26.1.0; absent from Node.js 24 and 22 (observed: startHeapProfile is not an export of node:v8 there).

v8.setHeapProfileNearHeapLimit()

1 Experimental

Added in v26.9.0.

worker.startHeapProfile(), worker.getHeapStatistics(), worker.getHeapSnapshot()

No stability index shown

startHeapProfile() since v24.9.0 and v22.20.0, getHeapStatistics() since v24.0.0 and v22.16.0, getHeapSnapshot() since v13.9.0 (also v12.17.0).

--max-old-space-size, --max-semi-space-size, --expose-gc

Flags (V8 options Node.js passes through)

Listed in the documentation as V8 options allowed in NODE_OPTIONS.

--max-old-space-size-percentage

Flag

The CLI page shows no added-in version. It worked on 22.23.3, 24.21.0 and 26.10.0 (observed).

--heapsnapshot-signal

Flag

Added in v12.0.0.

--heapsnapshot-near-heap-limit

Flag, no longer experimental

Added in v15.1.0 (also v14.18.0); no longer experimental in v25.4.0, v24.13.1 and v22.22.1.

--heap-prof and its -dir, -name, -interval companions

Flags, stable

Added in v12.4.0; stable since v22.4.0 and v20.16.0.

--trace-gc, --gc-interval

V8 flags

Not allowed in NODE_OPTIONS (observed: the process exits with "is not allowed in NODE_OPTIONS"); pass them on the command line or switch tracing from code with v8.setFlagsFromString().

PerformanceObserver with entryTypes: ['gc']

Stable (the node:perf_hooks module)

The entry’s kind and flags properties are Deprecated (Stability 0) since v16.0.0; read entry.detail instead.

How V8 organises and collects memory

A Node.js process uses several kinds of memory, and only one of them is the V8 heap that the flags on this page limit:

Table 2. Where a Node.js process keeps its memory
Region What lives there How it is reclaimed

V8 heap, young generation ("new space")

Newly created objects. It is small and collected often, on the bet that most objects die young.

A scavenge copies the survivors and drops the rest.

V8 heap, old generation ("old space")

Objects that survived young-generation collections: sessions, caches, module state, compiled code metadata.

A major (mark-compact) collection, run less often and more expensive; V8 runs much of the marking incrementally.

Other V8 heap spaces

Large objects, code, read-only data and V8-internal "trusted" data each have their own space (the table below).

Collected with the old generation or managed by V8.

Memory outside the heap ("external")

Buffer and ArrayBuffer contents, native add-on allocations, libuv and OpenSSL buffers.

Freed when the owning JavaScript object is collected; the heap limit does not bound it.

The stack

Call frames and local variables.

Popped when a function returns. Deep recursion overflows the stack, which is a separate failure from running out of heap.

The generational split is V8’s answer to the observation that, in a server, nearly everything allocated per request is garbage a moment later. Scavenges are cheap and frequent; the expensive work is reserved for the old generation. When your application retains data it should not, the old generation grows until a major collection cannot reclaim enough and the process aborts with a JavaScript heap out of memory fatal error. The V8 team’s posts on its collector ("Trash talk" and the Orinoco articles, linked in the references) explain the parallel, incremental and concurrent techniques behind this; what matters for Node.js programs is the cost model: collections pause or compete with your JavaScript, so garbage you never create is cheaper than garbage you collect.

Reading the heap limit and the heap spaces
import { getHeapStatistics, getHeapSpaceStatistics } from 'node:v8';

const mib = (bytes) => (bytes / 1024 / 1024).toFixed(1);

const { heap_size_limit, total_heap_size, used_heap_size } = getHeapStatistics();
console.log(`limit ${mib(heap_size_limit)} MiB, committed ${mib(total_heap_size)} MiB, used ${mib(used_heap_size)} MiB`);

for (const space of getHeapSpaceStatistics()) {
  console.log(space.space_name.padEnd(22), mib(space.space_used_size).padStart(7), 'MiB used of', mib(space.space_size), 'MiB');
}
limit 4192.0 MiB, committed 5.1 MiB, used 4.2 MiB
read_only_space            0.0 MiB used of 0.0 MiB
new_space                  0.4 MiB used of 0.5 MiB
old_space                  2.9 MiB used of 3.0 MiB
code_space                 0.0 MiB used of 0.3 MiB
shared_space               0.0 MiB used of 0.0 MiB
trusted_space              0.8 MiB used of 1.3 MiB
...
large_object_space         0.1 MiB used of 0.1 MiB

The output above is from Node.js 26.10.0 on a 32 GiB macOS machine (abridged; the full list has 13 spaces). Node.js 24.21.0 and 22.23.3 print limits of 4288 and 4144 MiB, and 22 has no shared_space or shared_trusted_space rows: the documentation states that neither the order nor the availability of a space is guaranteed, so never index into the array, look spaces up by space_name. The default limit depends on the machine (about 4 GiB here on a 32 GiB host), so measure it on the machine or container you deploy to rather than quoting a figure.

Table 3. The V8 heap spaces seen in the output, as V8 uses them
space_name Holds

new_space, new_large_object_space

The young generation (two semi-spaces) and its large objects.

old_space

Long-lived ordinary objects.

large_object_space

Objects too large for a regular page (for example big arrays); never copied.

code_space, code_large_object_space

Generated machine code.

read_only_space

Immutable data created at startup and shared by the isolate.

trusted_space and the trusted_* and shared_* variants

V8-internal data kept apart from ordinary objects, and data shared between isolates. Present in the Node.js 24 and 26 output, with fewer variants on 22.

Measuring memory

process.memoryUsage() returns five numbers, in bytes, and the difference between them is the first thing to learn: heapUsed and heapTotal describe the V8 heap, external is memory for C++ objects bound to JavaScript objects, arrayBuffers is the part of external used by ArrayBuffer, SharedArrayBuffer and Buffer, and rss is everything the operating system has resident for the process, including code and native libraries. process.memoryUsage.rss() returns just that one number and is faster.

heapUsed grows with objects, external grows with Buffers
import { memoryUsage, constrainedMemory, availableMemory } from 'node:process';

const mib = (n) => `${(n / 1024 / 1024).toFixed(1)} MiB`;
const show = (label) => {
  const { rss, heapTotal, heapUsed, external, arrayBuffers } = memoryUsage();
  console.log(label.padEnd(18), 'rss', mib(rss), '| heapUsed', mib(heapUsed), '/', mib(heapTotal),
    '| external', mib(external), '| arrayBuffers', mib(arrayBuffers));
};

show('start');
const heapOnly = Array.from({ length: 200_000 }, (_, i) => ({ id: i, title: `Book ${i}` }));
show('200k objects');
const outsideHeap = Buffer.alloc(100 * 1024 * 1024);
show('100 MiB Buffer');
console.log('keep alive:', heapOnly.length, outsideHeap.length);
console.log('constrainedMemory', constrainedMemory(), '(0 means no OS limit is known)');
console.log('availableMemory', mib(availableMemory()));
start              rss 52.2 MiB | heapUsed 4.6 MiB / 5.9 MiB | external 1.9 MiB | arrayBuffers 0.1 MiB
200k objects       rss 88.1 MiB | heapUsed 19.9 MiB / 35.2 MiB | external 1.9 MiB | arrayBuffers 0.1 MiB
100 MiB Buffer     rss 88.3 MiB | heapUsed 19.9 MiB / 35.2 MiB | external 101.9 MiB | arrayBuffers 100.1 MiB
keep alive: 200000 104857600
constrainedMemory 0 (0 means no OS limit is known)
availableMemory 16274.4 MiB

Two lessons are visible in the numbers (Node.js 26.10.0, macOS; Node.js 22.23.3 shows the same shape). The 100 MiB Buffer moved external and arrayBuffers but not heapUsed, so a heap limit would not have caught it; and rss did not move either, probably because the operating system had not yet touched the zero-filled pages. Do not read one counter in isolation. constrainedMemory() reports the limit the operating system imposes (for example a Linux control-group limit in a container) and returns 0 when there is none or it is unknown, as on this machine; availableMemory() reports what the process can still obtain. Both are the right inputs for choosing heap flags automatically.

v8.getHeapStatistics() adds the figures that matter for leak hunting: used_heap_size against heap_size_limit (how close you are), external_memory, and two counters the documentation singles out, number_of_native_contexts and number_of_detached_contexts. The documentation describes a detached context as one that was detached and not yet collected, and says a non-zero or rising count indicates a potential leak. v8.getHeapCodeStatistics() reports the size of generated code and metadata.

Sizing the heap: --max-old-space-size and friends

V8 aborts the process when the old generation cannot grow any further. The flags below move that ceiling, or shape the young generation.

Table 4. Memory flags
Flag What it does Notes

--max-old-space-size=SIZE

Sets the maximum size of V8’s old generation, in MiB. As memory use approaches the limit V8 spends more time collecting. The CLI documentation suggests, for a 2 GiB machine, 1536 so the rest of the system keeps some room.

Allowed in NODE_OPTIONS. heap_size_limit reports a larger number than you pass (observed: 512 gave 608 MiB on Node.js 26, 704 on 24, 560 on 22), presumably because it also counts the young generation.

--max-old-space-size-percentage=PERCENTAGE

Sets the old generation limit as a percentage of available system memory; the value must be above 0 and at most 100. It takes precedence over --max-old-space-size when both are given.

Allowed in NODE_OPTIONS. The documentation warns it builds on --max-old-space-size, which can overflow on 32-bit platforms. Observed on a 32 GiB machine: 25 gave an 8 GiB limit, 50 a 16 GiB one, and combining it with --max-old-space-size=512 still gave 8 GiB.

--max-semi-space-size=SIZE

Sets the maximum size of the young generation’s semi-spaces, in MiB. A larger value can raise throughput at the price of more memory.

Allowed in NODE_OPTIONS. In a synthetic loop that allocated 20 million short-lived objects the scavenge count barely moved (75 against 75 on Node.js 26 and 24, 83 against 76 on 22), so measure your own workload before adopting it.

--expose-gc

Defines a global gc() that forces a collection. Useful in tests that assert an object was released; the documentation cautions that manual collections add to, and do not replace, V8’s own and can hurt performance.

Allowed in NODE_OPTIONS.

--gc-interval=N

A V8 debugging flag: V8 describes it as "garbage collect after n allocations".

Not allowed in NODE_OPTIONS (observed). It forces a collection after every N allocations, so it adds collections; the Learn guide presents it as a way to reduce GC pauses, which is not what it does. It is a testing aid rather than a production tuning knob.

--max-heap-size

A V8 flag whose --v8-options text says the semi-space and old-space maxima take precedence and all three cannot be set together.

Listed as allowed in NODE_OPTIONS; prefer the two flags above.

Setting limits and checking the effect
node --max-old-space-size=512 heap-limit.mjs
node --max-old-space-size-percentage=25 heap-limit.mjs
NODE_OPTIONS="--max-old-space-size=1024" node heap-limit.mjs
limit 608.0 MiB, committed 5.1 MiB, used 4.2 MiB
limit 8288.0 MiB, committed 5.1 MiB, used 4.2 MiB
limit 1120.0 MiB, committed 5.1 MiB, used 4.2 MiB

A bigger heap is not a fix for a leak, it only delays the crash and makes each major collection (and each heap snapshot) slower. Raise the limit to match a real working set, and set it below the container’s memory limit with headroom for external memory, the stack and native libraries; Production Deployment and Docker & Containers cover memory sizing in containers. Inside the process you can also cap a single worker thread, which turns a runaway task into an error instead of a crash:

Limiting a worker’s heap with resourceLimits
import { Worker, isMainThread } from 'node:worker_threads';

if (isMainThread) {
  const worker = new Worker(new URL(import.meta.url), {
    resourceLimits: { maxOldGenerationSizeMb: 32 },
  });
  worker.on('error', (err) => console.log('worker failed:', err.code));
  worker.on('exit', (code) => console.log('worker exit code:', code, '- main thread is still alive'));
} else {
  const hoard = [];
  for (;;) hoard.push({ note: 'x'.repeat(100) });
}
worker failed: ERR_WORKER_OUT_OF_MEMORY
worker exit code: 1 - main thread is still alive

The same behaviour was observed on Node.js 26, 24 and 22. See Worker Threads for the rest of the worker API, and worker.getHeapStatistics() to watch a worker’s heap from the main thread.

Garbage collection traces

--trace-gc prints one line per collection to standard output. It is the cheapest tool in this page: no extra tooling, low overhead, and enough to tell a leak (live memory that never goes down) from a busy but healthy allocator. The leak below is deliberately blatant, an audit log whose entries are never removed:

A leak you can see in a GC trace
// A deliberately leaky "audit log": every lookup appends an entry that is never removed.
const audit = [];

function lookup(isbn) {
  audit.push({ isbn, at: Date.now(), note: 'x'.repeat(200) });
  return { isbn, title: `Book ${isbn}` };
}

for (let i = 0; i < 3_000_000; i++) {
  lookup(i);
}
console.log(`audit entries: ${audit.length}`);
node --trace-gc leaky-audit.mjs
[7533:0x7b8a80c000]        8 ms: Scavenge 4.5 (5.1) -> 4.2 (5.9) MB, pooled: 0.0 MB, 0.30 / 0.00 ms (average mu = 1.000, current mu = 1.000) allocation failure;
[7533:0x7b8a80c000]       24 ms: Scavenge 35.1 (43.7) -> 30.5 (47.6) MB, pooled: 0.0 MB, 2.22 / 0.00 ms (average mu = 1.000, current mu = 1.000) allocation failure;
[7533:0x7b8a80c000]       45 ms: Incremental Mark-Compact 64.5 (81.1) -> 58.0 (79.7) MB, pooled: 0.0 MB, 5.10 / 0.00 ms (+ 4.3 ms in 353 steps since start of marking, biggest step 0.0 ms, walltime since start of marking 18 ms) (average mu = 0.779, current mu = 0.779) allocation failure; GC in old space requested
[7533:0x7b8a80c000]       65 ms: Scavenge 99.7 (100.9) -> 90.7 (123.6) MB, pooled: 0.0 MB, 6.71 / 0.00 ms (average mu = 0.779, current mu = 0.779) allocation failure;

Each line breaks down like this (the first scavenge above):

Table 5. Anatomy of a --trace-gc line
Part Meaning

7533:0x7b8a80c000

The process id and the V8 isolate (the heap instance); a worker thread has its own isolate.

8 ms

Time since the process started.

Scavenge

The collection type: a young-generation scavenge, a Mark-Compact (a major collection), or an Incremental Mark-Compact when marking was spread over steps.

4.5 (5.1) → 4.2 (5.9) MB

Heap in use (and total committed) before, then after, the collection.

pooled: 0.0 MB

A V8-internal figure for pooled memory. It is absent from the sample output of the Learn guide, whose format predates it.

0.30 / 0.00 ms

Time the collection took, followed by a second duration; the Learn guide treats them together as time spent in GC.

average mu, current mu

V8’s mutator utilisation: the share of time your JavaScript could run. Near 1.000 the collector is out of the way; values falling toward 0 mean the application is being starved by collections.

allocation failure

Why the collection ran.

Reading a trace is mostly about two things. First, the after number of the major collections: if used heap after each major collection keeps climbing, the objects are reachable and the collector cannot help, which is a leak or an undersized heap. Second, the gap between collections against the time they take: collections that take longer than the time between them mean the application spends its life in the collector; a gap far larger than the pause is healthy; long gaps and long pauses together suggest the heap is larger than the work needs.

To see what the crash looks like, and which allocation was the last straw, shrink the limit until the process aborts:

node --trace-gc --max-old-space-size=64 leaky-audit.mjs
<--- Last few GCs --->

[12488:0x79dc80c000]      141 ms: Mark-Compact 63.1 (74.4) -> 63.1 (74.4) MB, pooled: 8.2 MB, 37.43 / 0.00 ms (average mu = 0.072, current mu = 0.001) allocation failure; GC in old space requested
[12488:0x79dc80c000]      176 ms: Mark-Compact (reduce) 63.1 (74.4) -> 62.3 (65.9) MB, pooled: 0.0 MB, 35.50 / 0.00 ms (average mu = 0.036, current mu = 0.007) last resort; GC in old space requested

FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory

Every major collection reclaimed almost nothing (63.1 → 63.1) while current mu sank to 0.001: the signature of a leak. The process exited with code 134 on macOS (observed; the abort signal). The Learn guide’s recipe for finding the allocation context is the same experiment: lower --max-old-space-size until the program hits the limit, read the log, raise it by about 10 percent and repeat; if the pattern persists, it is a leak, and if there is no crash, keep the heap at that size, since a packed heap reduces footprint.

All three lines print Mark-Compact for the major collection (observed on 22.23.3, 24.21.0 and 26.10.0), while the Learn guide’s text and sample output say Mark-sweep, the name of the older algorithm; match on the line shape, not on that word. --trace-gc is a V8 flag and, as noted in the map above, not accepted through NODE_OPTIONS. To trace only a window of the run, switch it from code:

Tracing only part of a run
import { setFlagsFromString } from 'node:v8';

const rows = [];
setFlagsFromString('--trace-gc'); // trace only this window...
for (let i = 0; i < 2_000_000; i++) rows.push({ i });
setFlagsFromString('--no-trace-gc'); // ...and switch it off again
console.log('rows', rows.length);

The documentation warns that changing V8 flags after startup can have unpredictable effects, including crashes, or may simply do nothing; treat this as a debugging aid and list the flags your Node.js version understands with node --v8-options. The same script printed 7, 6 and 13 scavenges on Node.js 26, 24 and 22 (observed), a reminder that the young-generation sizing differs between V8 versions.

For production monitoring, collect the data in-process instead of parsing text. PerformanceObserver with the 'gc' entry type delivers one entry per collection (entry.detail.kind says which kind), and v8.GCProfiler returns the heap statistics before and after every collection between start() and stop():

Counting collections with PerformanceObserver and GCProfiler
import { PerformanceObserver, constants } from 'node:perf_hooks';
import { GCProfiler } from 'node:v8';

const kinds = new Map([
  [constants.NODE_PERFORMANCE_GC_MAJOR, 'major'],
  [constants.NODE_PERFORMANCE_GC_MINOR, 'minor (scavenge)'],
  [constants.NODE_PERFORMANCE_GC_INCREMENTAL, 'incremental'],
  [constants.NODE_PERFORMANCE_GC_WEAKCB, 'weak callbacks'],
]);
const totals = new Map();

const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    const kind = kinds.get(entry.detail.kind) ?? `kind ${entry.detail.kind}`;
    const t = totals.get(kind) ?? { count: 0, ms: 0 };
    t.count++;
    t.ms += entry.duration;
    totals.set(kind, t);
  }
});
observer.observe({ entryTypes: ['gc'] });

const profiler = new GCProfiler();
profiler.start();

const kept = [];
for (let i = 0; i < 1_000_000; i++) {
  kept.push({ i });
  if (i % 4 !== 0) kept.pop(); // most objects die young, one in four survives
}

const report = profiler.stop();
await new Promise((resolve) => setTimeout(resolve, 50)); // entries are delivered asynchronously
observer.disconnect();

for (const [kind, { count, ms }] of totals) console.log(kind.padEnd(18), count, 'collections', ms.toFixed(1), 'ms');
const first = report.statistics[0];
console.log('GCProfiler:', report.statistics.length, 'events; first:', first.gcType,
  first.beforeGC.heapStatistics.usedHeapSize, '->', first.afterGC.heapStatistics.usedHeapSize, 'bytes');
minor (scavenge)   9 collections 7.3 ms
incremental        1 collections 0.1 ms
major              1 collections 1.9 ms
GCProfiler: 11 events; first: Scavenge 5128168 -> 4481080 bytes

The counts differ slightly between lines (Node.js 22.23.3 reported 12 scavenges for the same script). GCProfiler is documented as collecting data for the current thread only.

Heap snapshots

A heap snapshot is a complete picture of the V8 heap at one instant: every object, its size and what refers to it. Load one into Chrome DevTools (Memory tab) to see which constructors dominate the heap and, through the retainers panel, what keeps an object alive. The format is a JSON document whose schema the documentation calls undocumented and specific to V8, so it can change between V8 versions; use DevTools or another tool matching your Node.js version.

Snapshots are expensive and sensitive. The Learn guide warns that taking one stops all other work on the main thread, can take over a minute on a large heap, and is built in memory, so it can double the heap and, if the machine is short of memory, crash the process; take it from a process that can crash without affecting availability. The API documentation repeats this (about twice the heap size, with a risk of operating-system out-of-memory kills, and a synchronous operation that blocks the event loop). A snapshot also contains the contents of the strings in the heap, which means any token, password or personal data the process holds, so write it with restrictive permissions, keep it out of version control and container images, and never expose an HTTP route that triggers one.

Table 6. Ways to get a heap snapshot
Method How Notes

Inspector

Start with --inspect, open chrome://inspect or the DevTools, and use the Memory tab. See Debugging.

Works on every maintained line; needs the inspector port reachable, so keep it bound to localhost and never exposed.

Signal

node --heapsnapshot-signal=SIGUSR2 app.js, then kill -USR2 <pid>.

Since v12.0.0. SIGUSR1 is reserved by Node.js to start the inspector, so pick another signal.

API call

v8.writeHeapSnapshot([filename[, options]]) writes the file and returns its name; v8.getHeapSnapshot([options]) returns a Readable stream of the JSON.

Since v11.13.0. Call it from a signal handler or an authenticated, internal-only route. Worker threads have their own isolate, so a snapshot from the main thread has no information about the workers and the reverse; use worker.getHeapSnapshot() for a worker.

Near the heap limit

--heapsnapshot-near-heap-limit=N, or v8.setHeapSnapshotNearHeapLimit(N).

Writes up to N snapshots as the heap approaches its limit; the next section shows it.

Inspector protocol

Send HeapProfiler.takeHeapSnapshot over the inspector WebSocket from outside the process; the Learn guide shows a shell script with websocat and jq.

The DevTools front end is not required.

The leak this section hunts is a typical one: a search endpoint that remembers every distinct query in a Map and never evicts anything.

A leaking Bookshelf search service
import { createServer } from 'node:http';

class SearchSession {
  constructor(query) {
    this.query = query;
    this.results = new Array(1000).fill(query);
  }
}

// Leak: one entry per distinct query string, never evicted.
const sessions = new Map();

const server = createServer((req, res) => {
  const url = new URL(req.url, 'http://localhost');
  if (url.pathname === '/search') {
    const q = url.searchParams.get('q') ?? '';
    if (!sessions.has(q)) sessions.set(q, new SearchSession(q));
    res.end(`${sessions.size}\n`);
  } else {
    res.statusCode = 404;
    res.end();
  }
});

server.listen(0, '127.0.0.1', () => {
  console.log(`listening on ${server.address().port}`);
});

A small client sends a range of distinct queries (it takes the port and a start and end number):

A client that creates distinct queries
const port = process.argv[2];
const from = Number(process.argv[3]);
const to = Number(process.argv[4]);
for (let i = from; i < to; i++) {
  await (await fetch(`http://127.0.0.1:${port}/search?q=term-${i}`)).text();
}
console.log(`sent ${to - from} requests`);

Following the Learn guide’s two-snapshot method, let the process finish starting up, exercise the suspect feature once so its one-time allocations are out of the way, take a snapshot, keep using the feature, take a second one, and compare:

Taking two snapshots from a running process
node --heapsnapshot-signal=SIGUSR2 leaky-server.mjs &   # prints: listening on 50297
node hammer.mjs 50297 0 200
kill -USR2 %1                                           # first snapshot
node hammer.mjs 50297 200 1200
kill -USR2 %1                                           # second snapshot
Heap.20261004.010803.44534.0.001.heapsnapshot   14459750 bytes
Heap.20261004.010804.44534.0.002.heapsnapshot   42635798 bytes

The default name carries the date, time, process id, thread id (0 for the main thread) and a sequence number, and the files are written to the current working directory. In DevTools, load the older snapshot first and the newer second, select the newer one, switch the view from Summary to Comparison, and look for large positive deltas, then follow the retainers of those objects. Here the delta is one constructor, SearchSession, retained by a Map. To make the same comparison in a script, for instance in a regression test, count the objects of one constructor in each file (a snapshot is a JSON document, which is also why a very large one needs a lot of memory to open):

Counting instances of a class in a .heapsnapshot file
import { readFile } from 'node:fs/promises';

// Count objects per constructor name in a .heapsnapshot file (a JSON document).
const [file, className] = process.argv.slice(2);
const snapshot = JSON.parse(await readFile(file, 'utf8'));

const fields = snapshot.snapshot.meta.node_fields; // type, name, id, self_size, ...
const stride = fields.length;
const nameAt = fields.indexOf('name');
const sizeAt = fields.indexOf('self_size');
const typeAt = fields.indexOf('type');
const objectType = snapshot.snapshot.meta.node_types[0].indexOf('object');

let count = 0;
let bytes = 0;
for (let i = 0; i < snapshot.nodes.length; i += stride) {
  if (snapshot.nodes[i + typeAt] === objectType && snapshot.strings[snapshot.nodes[i + nameAt]] === className) {
    count++;
    bytes += snapshot.nodes[i + sizeAt];
  }
}
console.log(`${file}: ${count} x ${className}, ${bytes} bytes of self size`);
Heap.20261004.010803.44534.0.001.heapsnapshot: 200 x SearchSession, 8000 bytes of self size
Heap.20261004.010804.44534.0.002.heapsnapshot: 1200 x SearchSession, 48000 bytes of self size

The script reads the snapshot layout that V8 emits today (snapshot.meta.node_fields describes the flat nodes array); because the schema is undocumented, treat it as a debugging helper and not a stable parser. The counts match the number of distinct queries sent (200, then 1200), which is how you confirm a suspect before touching the code.

Instead of a signal flag, the application can write a snapshot on demand. Preloading a small module keeps it out of the application code:

Writing a snapshot on SIGUSR2 from a preloaded module
import { writeHeapSnapshot } from 'node:v8';

// Preload with: node --import ./snapshot-on-signal.mjs app.js
process.on('SIGUSR2', () => {
  // Synchronous: blocks the event loop until the file is written.
  const file = writeHeapSnapshot(undefined, { exposeInternals: false });
  console.log(`heap snapshot: ${file}`);
});

Run as node --import ./snapshot-on-signal.mjs leaky-server.mjs and signalled with kill -USR2, it wrote the snapshot on Node.js 26, 24 and 22. Do not combine it with --heapsnapshot-signal on the same signal.

When you need control over where the bytes go, such as a file with restricted permissions, getHeapSnapshot() returns a stream. It still builds the snapshot synchronously, so it does not avoid the pause:

Streaming a snapshot to a file with restricted permissions
import { getHeapSnapshot } from 'node:v8';
import { createWriteStream } from 'node:fs';
import { pipeline } from 'node:stream/promises';

const leak = Array.from({ length: 50_000 }, (_, i) => ({ id: i, title: `Book ${i}` }));

// Same snapshot as writeHeapSnapshot(), but you control the destination and permissions.
await pipeline(getHeapSnapshot({ exposeNumericValues: true }), createWriteStream('books.heapsnapshot', { mode: 0o600 }));
console.log('written, still holding', leak.length, 'objects');
written, still holding 50000 objects

The resulting file was 16 MB for about 50,000 small objects (Node.js 26.10.0). exposeInternals and exposeNumericValues (both default false) add V8-internal nodes and numeric values to the snapshot; leave them off unless a tool asks for them. See Streams for pipeline().

Snapshots when the process is about to run out of memory

You rarely get to attach a debugger at the moment of an out-of-memory crash, so Node.js can write the snapshot for you. --heapsnapshot-near-heap-limit=N writes up to N snapshots as heap usage approaches the limit, and v8.setHeapSnapshotNearHeapLimit(N) does the same from code (it is a no-op if the flag is already set or the call is repeated). The documentation notes that collections triggered while writing may bring usage down, so several snapshots can be written before the final crash and that they can be compared to see what was allocated between them; it also notes that the work needs both V8 heap and native memory, so the system may kill the process abruptly.

Equivalent to --heapsnapshot-near-heap-limit=1, chosen from code
import { setHeapSnapshotNearHeapLimit } from 'node:v8';

// Equivalent to --heapsnapshot-near-heap-limit=1, but chosen from code (for example, only in production builds).
setHeapSnapshotNearHeapLimit(1);

const audit = [];
for (let i = 0; i < 3_000_000; i++) audit.push({ i, note: 'x'.repeat(200) });
node --max-old-space-size=64 near-limit.mjs
Wrote snapshot to /srv/bookshelf/Heap.20261004.010852.73490.0.001.heapsnapshot
FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory

The path in the output above is illustrative; Node.js prints the real absolute path. Mind the size: with the heap capped at 64 MiB the two snapshots written by --heapsnapshot-near-heap-limit=2 were about 151 MB and 387 MB on Node.js 26 (188 MB and 294 MB on 22), because Node.js enlarges the heap to make room for the snapshot work. Make sure the disk and the memory limit of the container can absorb that, and pick a directory you can collect them from. Since v26.9.0 v8.setHeapProfileNearHeapLimit() (Experimental) can write a sampling heap profile in the same situation, as the next section describes.

The heap profiler

A snapshot answers "what is in memory now". The heap profiler answers "who allocated it", by recording allocations over a period. The Learn guide distinguishes two modes:

Table 7. Heap profiler modes
Mode What it records Cost

Allocation instrumentation timeline

Every allocation over time, shown in the DevTools Memory tab.

Higher overhead; the guide advises against it in production.

Sampling heap profiler

A statistical sample of allocations with their stack traces, summarised per function.

Low enough to use in production.

To use either interactively, start the application with node --inspect index.js (or --inspect-brk for a script that would finish before you attach), connect DevTools, choose the Memory tab, pick "Allocation instrumentation on timeline" or "Allocation sampling", start recording, apply load (the guide uses Apache Benchmark) and stop. Without a browser, Node.js can run the sampling profiler for you:

Table 8. Heap profile flags
Flag Effect

--heap-prof

Starts the V8 sampling heap profiler at startup and writes the profile to disk before exit, named Heap. plus date, time, process id, thread id and sequence, with the extension .heapprofile.

--heap-prof-dir

Directory for the profiles; otherwise the directory configured by --diagnostic-dir, or the current directory.

--heap-prof-name

Fixes the file name.

--heap-prof-interval

Average sampling interval in bytes; the default is 512 KiB (512 \* 1024).

Profiling allocations from the command line and ranking the allocators
import { readFile } from 'node:fs/promises';

// A .heapprofile is a tree of call frames; selfSize is what each frame allocated (and still holds when the profile was taken).
const profile = JSON.parse(await readFile(process.argv[2], 'utf8'));
const rows = [];
(function walk(node) {
  const { functionName, url, lineNumber } = node.callFrame;
  rows.push({ selfSize: node.selfSize, where: `${functionName || '(anonymous)'} ${url.split('/').pop()}:${lineNumber + 1}` });
  node.children.forEach(walk);
})(profile.head);

rows.sort((a, b) => b.selfSize - a.selfSize);
for (const { selfSize, where } of rows.slice(0, 5)) console.log(String(selfSize).padStart(10), 'bytes', where);
node --heap-prof --heap-prof-dir=prof --heap-prof-interval=4096 allocate.mjs
node top-allocators.mjs prof/*.heapprofile
  12299016 bytes loadCatalog allocate.mjs:3
     80536 bytes compileForInternalLoader realm:407
     24640 bytes getCLIOptionsFromBinding options:32
     20608 bytes getCLIOptionsFromBinding options:32
     19064 bytes compileForInternalLoader realm:407

Here allocate.mjs is a three-line script whose loadCatalog() function pushes 100,000 small objects into an array that outlives the call and whose renderTemplates() produces short-lived strings. The profile points at loadCatalog, the function whose allocations are still live, and not at the garbage that has already been collected (the sampler only keeps objects that are still alive unless told otherwise). Node.js 24.21.0 and 22.23.3 produced the same ranking. A .heapprofile file can also be loaded into the DevTools Memory tab.

Node.js 26.1.0 added a programmatic variant that avoids a whole-process flag and lets you profile just one window:

Profiling one window of code with v8.startHeapProfile (Node.js 26 only)
import { startHeapProfile } from 'node:v8';
import { writeFile } from 'node:fs/promises';

const kept = [];
using handle = startHeapProfile({ sampleInterval: 4096, stackDepth: 8 }); // Experimental handle, v26.1.0+
for (let i = 0; i < 100_000; i++) kept.push({ id: i, title: `Book ${i}` });

const profile = handle.stop(); // a JSON string in the .heapprofile format
await writeFile('window.heapprofile', profile, { mode: 0o600 });
console.log('profile bytes:', profile.length, 'kept:', kept.length);
profile bytes: 79701 kept: 100000

The options are sampleInterval (bytes, default 524288), stackDepth (default 16), forceGC, and includeObjectsCollectedByMajorGC and includeObjectsCollectedByMinorGC (both default false); the handle supports using and is discarded without a profile if disposed. On Node.js 24 and 22 the import fails with a SyntaxError ("does not provide an export named 'startHeapProfile'", observed), so use --heap-prof there, or worker.startHeapProfile() (v24.9.0 and v22.20.0) to profile a worker from outside. Feeding the profile to top-allocators.mjs ranked the anonymous module-level function, where the loop runs, first (observed).

Common leak patterns

Almost every Node.js leak is a reachable reference held by something that lives as long as the process. The usual holders:

Table 9. The usual suspects
Holder Typical leak Fix

Event emitters (including process, sockets and `EventTarget`s)

A listener added per request or per connection and never removed; the listener’s closure keeps the request’s data alive.

Remove the listener (off()/removeListener()) when the work ends, use once() for one-shot events, or pass { signal } to addEventListener().

Closures

A long-lived callback retains the scope it was created in, including large variables other closures in that scope use.

Keep large values in a scope no retained function shares; null out references you no longer need; avoid capturing whole requests.

Caches and registries

A Map, object or array keyed by user input (ids, queries, session tokens) that only grows.

Bound the size (an eviction policy or a TTL), use a proper cache library, or a WeakMap/WeakRef when entries should die with their key.

Timers

setInterval() callbacks (or long `setTimeout()`s) that are never cleared; each keeps its callback and everything it captured alive, and also keeps the process from exiting.

Store and clear the handle on shutdown or when the owner is closed; call timer.unref() for background timers that must not keep the process alive.

Other

Global arrays used for logging or metrics, promises that never settle and keep their continuations, unconsumed or unclosed streams, per-request objects stored on module state, `Buffer`s and native handles that live outside the heap.

Find them with a snapshot comparison; manage resources with try/finally, pipeline() and cancellation (see Async Context & Cancellation).

Listeners

EventEmitter prints a warning when more than ten listeners are added for one event on one emitter, because the default limit exists to catch exactly this mistake. The warning is named MaxListenersExceededWarning, and it carries the emitter, event name and count; --trace-warnings shows where it was raised. Raising the limit with setMaxListeners() silences the warning without removing the leak, so only do it when you have confirmed that many listeners are intended (the events guide describes this and the once()-based temporary adjustment). See Events & EventEmitter.

A listener leak, its warning, and two ways to clean up
import { EventEmitter, getEventListeners } from 'node:events';

const catalog = new EventEmitter(); // long-lived, shared by every request
const updates = new EventTarget(); // same idea with the Web API

process.on('warning', (w) => console.log(`${w.name} (${w.count} listeners on '${w.type}')`));

function handleRequestLeaky() {
  const response = { body: Buffer.alloc(1024) };
  // BUG: never removed, so the closure (and `response`) lives as long as `catalog`.
  catalog.on('catalog-updated', () => response.body.fill(0));
}

function handleRequestFixed() {
  const response = { body: Buffer.alloc(1024) };
  const onUpdate = () => response.body.fill(0);
  catalog.on('catalog-updated', onUpdate);
  return () => catalog.off('catalog-updated', onUpdate); // call when the request is done
}

for (let i = 0; i < 12; i++) handleRequestLeaky();
console.log('leaky listeners:', getEventListeners(catalog, 'catalog-updated').length);
catalog.removeAllListeners();

for (let i = 0; i < 12; i++) handleRequestFixed()(); // request starts, then finishes
console.log('fixed listeners:', getEventListeners(catalog, 'catalog-updated').length);

// EventTarget.addEventListener() understands { signal }: aborting removes the listener.
const controller = new AbortController();
updates.addEventListener('catalog-updated', () => {}, { signal: controller.signal });
console.log('before abort:', getEventListeners(updates, 'catalog-updated').length);
controller.abort();
console.log('after abort:', getEventListeners(updates, 'catalog-updated').length);
leaky listeners: 12
fixed listeners: 0
before abort: 1
after abort: 0
MaxListenersExceededWarning (11 listeners on 'catalog-updated')

The warning line prints last because Node.js emits it on a later tick. EventEmitter.on() has no signal option (only the EventTarget API and the static events.on()/events.once() helpers do), so for an emitter keep the listener function in a variable and remove it yourself, which also makes getEventListeners() a handy assertion in a test.

Closures, caches, WeakRef and timers

The next script measures what stays reachable after a forced collection for each remaining pattern (run with --expose-gc so it can call gc()):

Measuring retained memory for closures, caches, WeakRef and timers
// Run with: node --expose-gc leak-patterns.mjs
const heapMiB = () => {
  globalThis.gc(); // full collection, so only reachable objects are counted
  return process.memoryUsage().heapUsed / 1024 / 1024;
};
const baseline = heapMiB();
const report = (label) => console.log(label.padEnd(36), `${(heapMiB() - baseline).toFixed(1).padStart(6)} MiB above baseline`);

const registry = []; // long-lived: lives as long as the process

// 1. Closures: sibling closures share one scope object, so `onDone` keeps `payload` alive
//    even though only `summarize` uses it.
for (let i = 0; i < 20; i++) {
  const payload = new Array(100_000).fill(i);
  const summarize = () => payload.length;
  const onDone = () => i;
  registry.push(onDone); // `summarize` is dropped, `payload` is not
  summarize();
}
report('closures share a scope');
registry.length = 0;

for (let i = 0; i < 20; i++) {
  const summarize = (() => { const payload = new Array(100_000).fill(i); return () => payload.length; })();
  summarize();
  registry.push(() => i); // payload lives in a scope nobody retains
}
report('payload isolated in its own scope');
registry.length = 0;

// 2. Caches: unbounded Map keyed by user input versus a size-bounded one.
const unbounded = new Map();
for (let i = 0; i < 100_000; i++) unbounded.set(`isbn-${i}`, { title: 'x'.repeat(100) });
report('unbounded cache, 100k entries');
unbounded.clear();

class BoundedCache extends Map {
  constructor(max) { super(); this.max = max; }
  set(key, value) {
    if (this.has(key)) this.delete(key); // refresh insertion order
    super.set(key, value);
    if (this.size > this.max) this.delete(this.keys().next().value); // evict the oldest
    return this;
  }
}
const bounded = new BoundedCache(1_000);
for (let i = 0; i < 100_000; i++) bounded.set(`isbn-${i}`, { title: 'x'.repeat(100) });
report('bounded cache, 100k writes, max 1k');

// 3. WeakMap/WeakRef: entries keyed by an object disappear with that object.
const metadata = new WeakMap();
let book = { title: 'Dune' };
metadata.set(book, { seen: true });
const ref = new WeakRef(book);
book = null;
await new Promise((resolve) => setImmediate(resolve)); // a WeakRef target survives until the current job ends
globalThis.gc();
console.log('WeakRef target collected:'.padEnd(36), ref.deref() === undefined);

// 4. Timers: a repeating timer keeps its callback, and everything it captured, alive.
const timers = [];
for (let i = 0; i < 100; i++) {
  const buffer = new Array(10_000).fill(i);
  timers.push(setInterval(() => buffer.length, 60_000));
}
report('100 forgotten setInterval()s');
timers.forEach(clearInterval);
report('after clearInterval()');
closures share a scope                 15.3 MiB above baseline
payload isolated in its own scope       1.7 MiB above baseline
unbounded cache, 100k entries          29.7 MiB above baseline
bounded cache, 100k writes, max 1k      0.2 MiB above baseline
WeakRef target collected:            true
100 forgotten setInterval()s            7.9 MiB above baseline
after clearInterval()                   0.3 MiB above baseline

The numbers were identical to the digit (within 0.1 MiB) on Node.js 26, 24 and 22. Read them as follows. The first block shows that V8 allocates one scope object per loop iteration that sibling closures share, so retaining the harmless onDone retained all 20 arrays (about 16 MB); the second shows the same data released once it lives in a scope no retained function shares. The unbounded cache held 30 MB for 100,000 small entries and the bounded one under half a megabyte for the same traffic. Forgotten timers held their buffers until cleared. A WeakMap or WeakRef lets an entry vanish with its key, but a WeakRef target is only released after the current job finishes, so the script waits a tick before checking; the JavaScript runtime gives no promise about when a weak reference is cleared, so use these for caches that can be recomputed, not for correctness. See Timers for unref() and the timers promises API, and Collections for Map, WeakMap and WeakRef.

Detecting a leak in a running service

Watching heapUsed is misleading, since it rises between collections and falls after them. The better signal is the live heap after each major collection, which PerformanceObserver lets you sample, together with the limit:

A leak monitor that samples the live heap after major collections
import { PerformanceObserver, constants } from 'node:perf_hooks';
import { getHeapStatistics } from 'node:v8';

// After a major GC, used heap is (almost) only live data. If that floor keeps rising, something is retaining memory.
const floors = [];
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.detail.kind !== constants.NODE_PERFORMANCE_GC_MAJOR) continue;
    const { used_heap_size, heap_size_limit } = getHeapStatistics();
    floors.push(used_heap_size);
    const mib = (n) => (n / 1024 / 1024).toFixed(0);
    console.log(`major GC #${floors.length}: live heap ${mib(used_heap_size)} MiB of ${mib(heap_size_limit)} MiB`);
  }
});
observer.observe({ entryTypes: ['gc'] });

// Simulated leak: about 4 MiB of small objects retained per tick.
const retained = [];
const timer = setInterval(() => {
  for (let i = 0; i < 40_000; i++) retained.push({ i, note: 'book' });
  if (floors.length === 4) { clearInterval(timer); observer.disconnect(); }
}, 5);
node --max-old-space-size=256 leak-monitor.mjs
major GC #1: live heap 15 MiB of 352 MiB
major GC #2: live heap 27 MiB of 352 MiB
major GC #3: live heap 40 MiB of 352 MiB
major GC #4: live heap 55 MiB of 352 MiB

Performance entries are delivered asynchronously, so the value is read shortly after the collection and is approximate; the trend is what counts (Node.js 24 and 22 climbed the same way). Export the value to your metrics system, alert on a rising floor or on used_heap_size approaching heap_size_limit, and capture a snapshot when it fires. Once a leak is fixed, lock it in with a test. v8.queryObjects() runs a full collection and then counts live objects whose prototype chain includes a constructor, which is exactly what a leak regression test needs, without the cost of serialising a snapshot:

A leak regression test with queryObjects
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { queryObjects } from 'node:v8';

class SearchSession {
  constructor(query) { this.query = query; }
}

const sessions = new Map();
function openSession(query) { sessions.set(query, new SearchSession(query)); }
function closeSession(query) { sessions.delete(query); }

test('closing a session releases it', () => {
  for (let i = 0; i < 100; i++) openSession(`q${i}`);
  for (let i = 0; i < 100; i++) closeSession(`q${i}`);

  // queryObjects() runs a full GC first, then counts live instances of the constructor.
  assert.equal(queryObjects(SearchSession), 0);
});

test('a leak shows up as a count above zero', () => {
  for (let i = 0; i < 100; i++) openSession(`q${i}`); // never closed
  assert.equal(queryObjects(SearchSession), 100);
  assert.deepEqual(queryObjects(SearchSession, { format: 'summary' }).slice(0, 1), ["SearchSession { query: 'q0' }"]);
});
node --test leak-regression.test.mjs

Both tests passed on Node.js 26, 24 and 22, each printing an ExperimentalWarning for v8.queryObjects(); the documentation says the API is no longer experimental since v25.4.0 and v24.13.1, but the runtime warning was still emitted (observed), so it is cosmetic. The documentation advises against calling the API on constructors you do not control, and only objects created in the current execution context are counted. See Test Runner for the runner.

The overall workflow follows from the pieces above:

  • Confirm it is a leak. Plot live heap after major collections (or the --trace-gc "after" numbers); a flat floor is not a leak.

  • Rule out non-heap memory. Compare rss, heapUsed, external and arrayBuffers; a growing external points at Buffers, streams or native add-ons, and no heap tool will find it.

  • Capture two snapshots under steady load, with the one-time start-up allocations behind you, and compare them.

  • Find the retainer. Follow the retainers of the largest positive delta to the Map, emitter, timer or closure that holds it.

  • Fix, then prove it with a test that fails before the fix (queryObjects() or a snapshot count) and a dashboard that stays flat after.

What changed recently

Table 10. Memory-related changes by Node.js version
Version Change

v26.9.0

v8.setHeapProfileNearHeapLimit() (Experimental) writes the active sampling heap profile near the heap limit.

v26.1.0

v8.startHeapProfile() and SyncHeapProfileHandle (Experimental handle) add a programmatic sampling heap profile.

v25.5.0, v24.13.1

GCProfiler instances support using ([Symbol.dispose]()).

v25.4.0, v24.13.1, v22.22.1

--heapsnapshot-near-heap-limit is no longer experimental; v8.queryObjects() is no longer experimental in v25.4.0 and v24.13.1.

v24.9.0, v22.20.0

worker.startHeapProfile() profiles a worker from the main thread.

v24.0.0, v22.16.0 and v22.17.0

process.constrainedMemory() and process.availableMemory() are Stable (v24.0.0, v22.16.0); v8.setHeapSnapshotNearHeapLimit() is Stable (v24.0.0, v22.17.0); worker.getHeapStatistics() added.

v22.4.0, v20.16.0

The --heap-prof family of flags is stable.

v22.0.0, v20.13.0

v8.queryObjects() and process.availableMemory() added.

v19.6.0, v18.15.0

v8.GCProfiler and process.constrainedMemory() added.

v19.1.0

writeHeapSnapshot() and getHeapSnapshot() accept the exposeInternals and exposeNumericValues options.

v18.0.0

writeHeapSnapshot() throws if the file could not be written, with consistent error codes across platforms.

v16.0.0

The kind and flags properties of 'gc' performance entries are Deprecated in favour of entry.detail.

The V8 version also moved with each line (12.4 on Node.js 22, 13.6 on 24, 14.6 on 26), which is why trace lines gained a pooled: field, why the list of heap spaces differs, and why a trace or snapshot from one line may not match another’s tooling exactly.

Where the books are outdated

The two books touch memory only lightly, so they are not a source for this topic. Efficient Node.js (Samer Buna, O’Reilly 2025) mentions that the node command accepts V8 options, including ones that tune the engine’s memory management, tells you to look at DevTools' memory inspector, and in its streams chapter compares the process’s memory with and without streams by watching it grow; it does not describe the heap spaces, garbage collection, snapshots or leak patterns. Node.js Projects (Jonathan Wexler, O’Reilly 2025) names V8 and recommends profiling for excessive memory use as part of mastering the craft, with no tooling. Where you follow either book, apply these points:

Table 11. Where the books are silent or narrower
Topic Current approach

"V8 has options for memory management"

Name them: --max-old-space-size, --max-old-space-size-percentage (it takes precedence over the absolute flag), --max-semi-space-size, and --expose-gc are the ones allowed in NODE_OPTIONS; --trace-gc and --gc-interval are not.

Watching a process’s memory in a monitor

Use the numbers the runtime exposes (process.memoryUsage(), v8.getHeapStatistics()) and separate heap from external and rss; a Buffer-heavy workload will look healthy on the heap while it exhausts the machine.

The memory inspector

Use heap snapshots, comparison and retainers, written by --heapsnapshot-signal or --heapsnapshot-near-heap-limit when you cannot attach an inspector; the heap profiler and --heap-prof answer "who allocated".

"Memory leaks"

Neither book covers heap leaks in depth; check listeners (watch for MaxListenersExceededWarning), closures, unbounded caches and timers, as above.

The stream memory comparison

The shape is right; see Streams for current numbers on 26, 24 and 22, and treat any absolute figure in a book as one machine’s result.

Pitfalls and good practice

  • A bigger heap is not a fix. It delays the crash and lengthens each major collection; size it to the working set and leave headroom below the container limit.

  • Heap flags do not bound Buffer`s. A 100 MiB `Buffer raised external and arrayBuffers but not heapUsed; watch rss and external too.

  • Do not call gc() in production code. --expose-gc is for tests; a forced collection is a pause you chose.

  • Snapshots pause the process and are large. Take them from an instance that can be replaced, restrict the file’s permissions, and delete them afterwards: they contain the heap’s strings.

  • Never expose a snapshot trigger. Prefer a signal handler or an internal-only authenticated route, and keep the inspector on localhost.

  • Do not rely on weak references for correctness. A WeakRef is cleared at an unspecified time; use them for recomputable caches.

  • Do not silence MaxListenersExceededWarning by raising the limit before you have removed the leak.

  • --trace-gc and --gc-interval cannot go in NODE_OPTIONS. Pass them as arguments, or use v8.setFlagsFromString() for a window.

  • Keep timers cleanable. Store the handle, clear it on shutdown, and unref() background timers; see Process, Signals & Exit Codes.

  • Mind the pause cost. Long collections add latency; see Don’t Block the Event Loop. For the equivalent in a framework see NestJS: Debugging & DevTools and NestJS: Performance Optimization.

References