Skip to content

Continuous GC loop when using 'gc' PerformanceObserver on Node v24+ #62721

@martenk-bc

Description

@martenk-bc

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions