Version
v24.14.1, v25.9.0
Platform
Linux cd2ade5beb2d 6.1.166-197.305.amzn2023.x86_64 #1 SMP PREEMPT_DYNAMIC Mon Mar 23 09:53:26 UTC 2026 x86_64 x86_64 x86_64 GNU/Linux
Darwin 25.4.0 Darwin Kernel Version 25.4.0: Thu Mar 19 19:33:25 PDT 2026; root:xnu-12377.101.15~1/RELEASE_ARM64_T6041 arm64 arm
Subsystem
perf_hooks
What steps will reproduce the bug?
When a PerformanceObserver is activated for gc entries, the GC can enter a continuous loop where new GCs are immediately triggered by the callback.
The provided reproduction script runs for 10 seconds. The first 5 seconds it provides a good amount of allocation pressure to trigger GCs. After 5 seconds, the allocations are stopped.
With the PerformanceObserver installed, the script will continue to perform back-to-back GCs until the script exits:
[88847:0x7f7ee8008000] 10079 ms: Mark-Compact 20.3 (86.1) -> 20.2 (86.1) MB, pooled: 22 MB, 0.48 / 0.00 ms (+ 2.5 ms in 148 steps since start of marking, biggest step 0.1 ms, walltime since start of marking 4 ms) (average mu = 0.382, current mu = 0.378) finalize incremental marking via task; GC in old space requested
[88847:0x7f7ee8008000] 10084 ms: Mark-Compact 20.3 (86.1) -> 20.2 (86.1) MB, pooled: 22 MB, 0.42 / 0.00 ms (+ 2.6 ms in 145 steps since start of marking, biggest step 0.1 ms, walltime since start of marking 4 ms) (average mu = 0.385, current mu = 0.388) finalize incremental marking via task; GC in old space requested
[88847:0x7f7ee8008000] 10089 ms: Mark-Compact 20.3 (86.1) -> 20.2 (86.1) MB, pooled: 22 MB, 0.66 / 0.00 ms (+ 2.7 ms in 136 steps since start of marking, biggest step 0.1 ms, walltime since start of marking 4 ms) (average mu = 0.368, current mu = 0.351) finalize incremental marking via task; GC in old space requested
Without the PerformanceObserver, there are virtually no new GCs after the allocation pressure is stopped.
Run the following reproduction script with node --max-old-space-size=80 --trace-gc on Node v24.0.0+. The max old space is needed so that the baseline+allocations trigger the right amount of GC pressure. The GC tracing makes the issue visible by showing the hundreds of GCs per second.
const { PerformanceObserver } = require('perf_hooks');
const observer = new PerformanceObserver(() => { });
observer.observe({ entryTypes: ['gc'] });
const baseline = [];
for (let k = 0; k < 1600; k++) {
let s = '';
for (let j = 0; j < 200; j++) s += (k * 200 + j).toString(36).padStart(6, '0');
baseline.push({ id: k, payload: s });
}
let pool = [];
let i = 0;
const allocTimer = setInterval(() => {
i++;
pool.push({ id: i, data: new Array(2000).fill(i), str: 'x'.repeat(100) });
if (pool.length > 1000) pool.splice(0, 200);
}, 1);
setTimeout(() => {
clearInterval(allocTimer);
pool = null;
console.log(' → alloc phase ended, entering idle …');
}, 5000);
setTimeout(() => {
void baseline;
process.exit(0);
}, 10000);
repro-gc-observer-inline.js
How often does it reproduce? Is there a required condition?
The reproduction script reliably reproduces the issue for me. It contains all the necessary preconditions:
PerformanceObserver has to be observing the gc entry type.
- Some baseline heap occupancy seems to be required.
- GC pressure from short-lived objects trigger the issue.
What is the expected behavior? Why is that the expected behavior?
I expect very low GC activity when the GC pressure from allocations stops.
What do you see instead?
We're seeing a continuous loop of hundreds of GCs per second, with low heap occupancy and near-zero new allocations, leading to very high CPU usage.
Here's the output log for the reproduction script, output.log
In our production environments, once triggered, the issue lasted for hours before we restarted the affected services.
Additional information
Issue is reproducible on v24.0.0+, including on the current main branch. It's not reproducible on versions up to v23.11.1.
The issue is not reproducible when passing in either --incremental-marking-soft-trigger or --incremental-marking-hard-trigger with any value. There's likely an interaction with v8's Heap::IncrementalMarkingLimitReached().
The issue was found in the context of OpenTelemetry instrumentation, which collects metrics for GCs: https://github.com/open-telemetry/opentelemetry-js-contrib/blob/main/packages/instrumentation-runtime-node/src/metrics/gcCollector.ts
Version
v24.14.1, v25.9.0
Platform
Subsystem
perf_hooks
What steps will reproduce the bug?
When a
PerformanceObserveris activated forgcentries, the GC can enter a continuous loop where new GCs are immediately triggered by the callback.The provided reproduction script runs for 10 seconds. The first 5 seconds it provides a good amount of allocation pressure to trigger GCs. After 5 seconds, the allocations are stopped.
With the
PerformanceObserverinstalled, the script will continue to perform back-to-back GCs until the script exits:Without the
PerformanceObserver, there are virtually no new GCs after the allocation pressure is stopped.Run the following reproduction script with
node --max-old-space-size=80 --trace-gcon Node v24.0.0+. The max old space is needed so that the baseline+allocations trigger the right amount of GC pressure. The GC tracing makes the issue visible by showing the hundreds of GCs per second.repro-gc-observer-inline.js
How often does it reproduce? Is there a required condition?
The reproduction script reliably reproduces the issue for me. It contains all the necessary preconditions:
PerformanceObserverhas to be observing thegcentry type.What is the expected behavior? Why is that the expected behavior?
I expect very low GC activity when the GC pressure from allocations stops.
What do you see instead?
We're seeing a continuous loop of hundreds of GCs per second, with low heap occupancy and near-zero new allocations, leading to very high CPU usage.
Here's the output log for the reproduction script, output.log
In our production environments, once triggered, the issue lasted for hours before we restarted the affected services.
Additional information
Issue is reproducible on v24.0.0+, including on the current main branch. It's not reproducible on versions up to v23.11.1.
The issue is not reproducible when passing in either
--incremental-marking-soft-triggeror--incremental-marking-hard-triggerwith any value. There's likely an interaction with v8's Heap::IncrementalMarkingLimitReached().The issue was found in the context of OpenTelemetry instrumentation, which collects metrics for GCs: https://github.com/open-telemetry/opentelemetry-js-contrib/blob/main/packages/instrumentation-runtime-node/src/metrics/gcCollector.ts