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