How to Capture Node.js GC Traces Without Guessing

Node.js gives you four different ways to watch the garbage collector work, from a single CLI flag to a dedicated GCProfiler class.

How to Capture Node.js GC Traces Without Guessing
Photo by Kenny Eliason / Unsplash

A Node.js process that's slowing down under load and one that's leaking memory look identical from the outside: response times climb, then the process gets killed and restarted. The only way to tell them apart is to watch what the garbage collector is actually doing, and Node.js gives you four separate ways to do that, each surfacing different information at a different cost.

How to trace garbage collection in Node.js

Node.js exposes GC activity through a CLI flag, a runtime API for toggling that same flag on the fly, a dedicated profiler class, and a structured event stream, covered in that order below, from the least to the most code required.

The trace line, decoded

The fastest way to see GC activity is the --trace-gc flag, which prints one line per collection:

node --trace-gc script.mjs

A single line looks like this:

[13973:0x110008000]       44 ms: Scavenge 2.4 (3.2) -> 2.0 (4.2) MB, 0.5 / 0.0 ms  (average mu = 1.000, current mu = 1.000) allocation failure

Every token in that line means something specific:

Token Meaning
13973 PID of the process
0x110008000 The V8 isolate (JS heap instance)
44 ms Time since process start
Scavenge The GC algorithm that ran
2.4 (3.2) Heap used / total heap, before collection, in MB
2.0 (4.2) Heap used / total heap, after collection, in MB
0.5 / 0.0 ms Time spent in this GC pass
allocation failure What triggered the collection

V8's heap is split into a "new" space and an "old" space. Scavenge is the algorithm that cleans the new space, where every object starts out. It's a copying collector, cheap enough to run frequently, and it promotes anything that survives two scavenges into old space. Mark-sweep (sometimes logged as Mark-Compact) cleans old space instead, in two phases: a mark phase that walks live objects from the roots and flags everything reachable, and a sweep phase that reclaims everything left unflagged. Mark-sweep passes cost more, which is why a process dominated by them, rather than by scavenges, is usually a process under real memory pressure.

Watching a leak happen

This script leaks on purpose, adapted from the Node.js diagnostics guide. It fills a Set with a million entries and never releases them:

import os from 'node:os';
 
let len = 1_000_000;
const entries = new Set();
 
function addEntry() {
  const entry = {
    timestamp: Date.now(),
    memory: os.freemem(),
    totalMemory: os.totalmem(),
    uptime: os.uptime(),
  };
  entries.add(entry);
}
 
function summary() {
  console.log(`Total: ${entries.size} entries`);
}
 
(() => {
  while (len > 0) {
    addEntry();
    len--;
  }
  summary();
})();

Running it with node --trace-gc script.mjs produces a run of Scavenge lines climbing steadily instead of settling back down:

[39067:0x158008000]     2297 ms: Scavenge 117.5 (135.8) -> 102.2 (135.8) MB, 0.8 / 0.0 ms  (average mu = 0.994, current mu = 0.994) allocation failure
[39067:0x158008000]     2375 ms: Scavenge 120.0 (138.3) -> 104.7 (138.3) MB, 0.9 / 0.0 ms  (average mu = 0.994, current mu = 0.994) allocation failure
[39067:0x158008000]     2453 ms: Scavenge 122.4 (140.8) -> 107.1 (140.8) MB, 0.7 / 0.0 ms  (average mu = 0.994, current mu = 0.994) allocation failure

That pattern, used-after-GC climbing on every line instead of dropping back toward a baseline, is what a leak looks like in raw trace output, well before it shows up as a production incident.

Forcing the leak to confess. If old-space usage keeps climbing, cap the heap with --max-old-space-size and let the process hit the wall on purpose:

node --trace-gc --max-old-space-size=50 script.mjs

That produces Mark-sweep passes that reclaim almost nothing before the process dies with FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory. Bump the limit by roughly 10% and rerun; if the same OOM pattern shows up at the higher ceiling, that confirms a real leak rather than a workload that's simply heap-hungry. If a limit exists where the process runs clean, that's your production heap size.

There's a rule of thumb for reading the gaps between collections, too: if the time between two GC passes is shorter than the time spent inside those passes, the process is GC-starved and needs either less allocation pressure or more heap. If the gaps are long relative to GC time, the process is healthy and the heap may even be oversized for the workload.

Toggling traces at runtime with node:v8

--trace-gc runs for the process's entire lifetime, which is a lot of noise if only one code path is suspect. The node:v8 module's setFlagsFromString() function flips V8 flags on the fly:

import { setFlagsFromString } from 'node:v8';
import { setInterval } from 'node:timers';
 
// Start tracing GC events
setFlagsFromString('--trace-gc');
 
// Trigger GC events by using some memory
let arrays = [];
const interval = setInterval(() => {
  for (let i = 0; i < 500; i++) {
    arrays.push(new Array(10000).fill(Math.random()));
  }
  if (arrays.length > 5000) {
    arrays = arrays.slice(-1000);
  }
}, 100);
 
// Stop tracing after 1.5 seconds
setTimeout(() => {
  setFlagsFromString('--notrace-gc');
}, 1500);
 
setTimeout(() => {
  clearInterval(interval);
}, 2500);

v8.setFlagsFromString() accepts any V8 command-line flag as a string, at runtime, on a live process. That's also its main hazard: the Node.js docs are explicit that flipping flags after the VM has started can produce unpredictable behavior, so this belongs around a suspect code path during debugging, not wrapped around routine production traffic.

The one most people miss: v8.GCProfiler

Every method above produces either loose text or an event you have to aggregate yourself. node:v8 also exposes a GCProfiler class, added in Node.js v18.15/v19.6, that collects a structured report across a whole window of time in one call:

import { GCProfiler } from 'node:v8';
 
const profiler = new GCProfiler();
profiler.start();
 
setTimeout(() => {
  console.log(profiler.stop());
}, 1000);

profiler.stop() returns a single object with a statistics array: one entry per GC pass, each carrying gcType, a cost in milliseconds, and full heap statistics captured immediately before and after that pass. That's the entire before/after picture for a time window without hand-rolling any aggregation logic, which makes it the most direct fit for an automated health check that snapshots GC behavior around a specific operation (a deploy, a batch job, a load-test window) and asserts on the result.

Structured events with perf_hooks

The node:perf_hooks module surfaces GC as PerformanceEntry objects through the standard PerformanceObserver API, which is the right fit when GC data needs to sit alongside other performance entries (HTTP timings, custom marks) in one observability pipeline:

const { PerformanceObserver } = require('node:perf_hooks');
 
const obs = new PerformanceObserver((list) => {
  const entry = list.getEntries()[0];
  // entry looks like:
  // PerformanceEntry {
  //   name: 'gc',
  //   entryType: 'gc',
  //   startTime: 2820.567669,
  //   duration: 1.315709,
  //   kind: 1
  // }
});
 
obs.observe({ entryTypes: ['gc'] });
 
// obs.disconnect() when done

The kind value maps to one of five constants exported on perf_hooks.constants: NODE_PERFORMANCE_GC_MAJOR, NODE_PERFORMANCE_GC_MINOR, NODE_PERFORMANCE_GC_MINOR_MARK_SWEEP, NODE_PERFORMANCE_GC_INCREMENTAL, and NODE_PERFORMANCE_GC_WEAKCB. As of Node.js 16, kind and flags moved from top-level properties on the entry into a detail object (entry.detail.kind, entry.detail.flags). The top-level versions are deprecated but still present for now. PerformanceObserver instances carry their own overhead, so the docs recommend disconnecting them as soon as the observation window is over rather than leaving one subscribed indefinitely.

How real projects use this

The four methods above are the primitives. Two widely-used open source projects show what gets built on top of them.

Exporting GC time to Prometheus: prom-client. prom-client is the standard Prometheus client for Node.js, pulling millions of weekly downloads. Its default metrics collector reports GC duration as a proper histogram, and the implementation is a direct, production-hardened version of the perf_hooks pattern above:

// lib/metrics/gc.js, prom-client (Apache-2.0)
// https://github.com/siimon/prom-client
const kinds = [];
 
if (perf_hooks && perf_hooks.constants) {
	kinds[perf_hooks.constants.NODE_PERFORMANCE_GC_MAJOR] = 'major';
	kinds[perf_hooks.constants.NODE_PERFORMANCE_GC_MINOR] = 'minor';
	kinds[perf_hooks.constants.NODE_PERFORMANCE_GC_INCREMENTAL] = 'incremental';
	kinds[perf_hooks.constants.NODE_PERFORMANCE_GC_WEAKCB] = 'weakcb';
}
 
const obs = new perf_hooks.PerformanceObserver(list => {
	const entry = list.getEntries()[0];
	// Node < 16 uses entry.kind
	// Node >= 16 uses entry.detail.kind
	const kind = entry.detail ? kinds[entry.detail.kind] : kinds[entry.kind];
	// Convert duration from milliseconds to seconds
	gcHistogram.observe(Object.assign({ kind }, labels), entry.duration / 1000);
});
 
obs.observe({ entryTypes: ['gc'] });

Two things worth noticing. First, the kinds lookup array is built directly from the perf_hooks.constants values covered above, exactly the mapping from numeric kind to a readable label (major, minor, incremental, weakcb) that turns a raw PerformanceEntry into something a dashboard can group by. Second, the entry.detail ? ... : ... branch is the library handling the exact API change mentioned earlier: Node 16 moved kind off the top-level entry and into entry.detail.kind, and this line is what lets prom-client keep working across both API shapes without the caller ever knowing which one their Node version uses. That's the kind of detail that only shows up when a library has to support a wide version matrix in production, not in a single-Node-version tutorial.

A real server built to have GC problems: Clinic.js's slow-gc example. Clinic.js is a Node.js performance diagnostics suite originally built by NearForm. Its example repo ships a small Restify server, slow-gc, purpose-built to demonstrate a GC-bound performance problem for clinic doctor to diagnose:

// slow-gc/index.js, node-clinic-doctor-examples (MIT, NearForm and Clinic.js Contributors)
// https://github.com/clinicjs/node-clinic-doctor-examples
function createLargeLinkStructure (tail, item, repeats, callback) {
  setImmediate(function () {
    if (repeats > 0) {
      const next = JSON.parse(item)
      tail.next = next
      createLargeLinkStructure(next, item, repeats - 1, callback)
    } else {
      callback(null, tail)
    }
  })
}
 
function getLargeData (data, callback) {
  const item = JSON.stringify(data)
  const first = JSON.parse(item)
  createLargeLinkStructure(first, item, 512, function (err, last) {
    if (err) return callback(err)
    last.next = first
    callback(null, first)
  })
}

Every request to this server's one route calls getLargeData, which serializes the payload once and then deserializes it 512 times in a row to build a long linked list of freshly-allocated objects, purely to generate garbage collection pressure on every single request. It's a deliberately exaggerated version of a real pattern: any request handler that deep-clones a large object multiple times (a common side effect of some validation or ORM libraries) puts exactly this kind of load on the scavenger.

Run it yourself and watch it in real time:

git clone https://github.com/clinicjs/node-clinic-doctor-examples
cd node-clinic-doctor-examples/slow-gc
npm install
node --trace-gc index.js
# in another terminal:
npx autocannon -c 100 http://localhost:3000

The Scavenge lines should start arriving in a steady stream the moment traffic hits, which is the same signal covered in the leak-reproduction script earlier in this article, just triggered by request volume instead of an unbounded loop. Point clinic doctor -- node index.js at it instead of --trace-gc and Clinic.js will produce the same diagnosis (a GC-bound process) as a visual report instead of a raw trace.

Comparing the four methods

Method Setup Output Runtime toggle Best for
--trace-gc flag Zero code, CLI only Plain text, one line per GC No, process-lifetime only Quick local reproduction of a suspected leak
v8.setFlagsFromString() One import, two calls Same plain text as the flag Yes, enable/disable around a code path Isolating tracing to one suspect route or job
v8.GCProfiler One import, start()/stop() Structured object, full before/after heap stats per pass Yes, explicit start/stop window Automated checks, deploy-window health snapshots
perf_hooks PerformanceObserver Observer + entryTypes: ['gc'] Structured PerformanceEntry stream Yes, observe/disconnect Feeding GC data into an existing metrics pipeline

What this doesn't cover

None of the four methods above tell you which objects are being retained. They only show that collection is happening and how expensive it was. Finding the actual leaked object requires a heap snapshot (via v8.writeHeapSnapshot() or Chrome DevTools' memory panel) and a diff between two snapshots taken over time, which is a separate workflow from trace-reading. This also doesn't cover CPU flame-graph profiling, which answers a different question (where time goes, not where memory goes), or the mechanics of tuning --max-old-space-size and --max-semi-space-size for a production workload, which depends heavily on the specific application's allocation patterns. And everything here is single-isolate: a heap snapshot or GC trace taken on the main thread carries no information about worker_threads, which each run their own isolate and need to be traced separately.

For memory issues that trace data alone can't pin down, our event loop deep dive covers the other half of Node.js runtime behavior worth understanding before you start guessing at fixes. A process that's GC-starved and a process that's blocking the event loop produce similar-looking latency graphs for very different reasons.