Repository navigation
v24/v26: idle memory not released for ~100 s after load (V8 memory reducer regression) #66564
Description
Version
v24.21.0 (V8 13.6.233.17), v26.10.0 (V8 14.6.202.34). Not reproducible on v22.23.3 (V8 12.4).
Platform
Linux arm64 (Docker `node:*-bookworm-slim`); first noticed on x86_64 in production.
Subsystem
v8
What steps will reproduce the bug?
Save as repro.js, run node --trace-gc --trace-memory-reducer --trace-mutator-utilization repro.js.
Allocates ~1 GB of short-lived objects for 5 s, drops them, idles 150 s with a 2 s setInterval
logging process.memoryUsage().
'use strict';
// node --trace-gc --trace-memory-reducer --trace-mutator-utilization repro-min.js
// 5 s allocation burst, drop everything, idle 150 s logging process.memoryUsage() every 2 s.
const t0 = Date.now();
const mb = (n) => (n / 1048576).toFixed(1);
function log(tag) {
const m = process.memoryUsage();
console.log(`[${((Date.now() - t0) / 1000).toFixed(1)}s] ${tag} rss=${mb(m.rss)}MB heapTotal=${mb(m.heapTotal)}MB heapUsed=${mb(m.heapUsed)}MB`);
}
function makeChunk(i) { // ~2.5 MB of objects and strings
const arr = new Array(20000);
for (let j = 0; j < arr.length; j++) {
arr[j] = { id: i * 100000 + j, name: 'item-' + i + '-' + j, tags: [j, j + 1, 'x' + j], payload: 'p'.repeat(32) + j };
}
return arr;
}
log(`start node=${process.version} v8=${process.versions.v8}`);
let window = [], i = 0;
const burstEnd = Date.now() + 5000;
(function burst() { // sliding window of ~48 live chunks; run in 50 ms slices so GC tasks can interleave
const sliceEnd = Date.now() + 50;
while (Date.now() < sliceEnd) { window.push(makeChunk(i++)); if (window.length > 48) window.shift(); }
if (Date.now() < burstEnd) return setImmediate(burst);
log(`burst done chunks=${i}`);
window = null; // everything is now garbage
const idleStart = Date.now();
const iv = setInterval(() => {
log('idle');
if (Date.now() - idleStart >= 150000) { clearInterval(iv); log('end'); process.exit(0); }
}, 2000);
})();(Same script plus idle-mode variants, raw traces, flag experiments and a patched/control Node 24 build:
https://github.com/acburdine/node-memory-reducer-repro)
How often does it reproduce? Is there a required condition?
Every run.
What is the expected behavior? Why is that the expected behavior?
As on v22: a few reducer ticks after allocation stops, Memory reducer: low alloc → Mark-Compact (reduce), heap and RSS drop.
What do you see instead?
16283 ms: Embedder mutator utilization = 0.000 (mutator_speed=0, gc_speed=1)
16283 ms: Memory reducer: high alloc, foreground
16283 ms: Memory reducer: waiting for 8000 ms
... identical every 8 s ...
105424 ms: Memory reducer: started GC #1 <- watchdog, 100 s after the last major GC
105559 ms: Mark-Compact (reduce) 681.6 (777.1) -> 4.3 (20.6) MB
Time from end of burst to first reduce GC:
| idle mode | v22.23.3 | v24.21.0 | v26.10.0 | v24.21.0 + patch |
|---|---|---|---|---|
| 2 s timer | 44 s | 100 s (watchdog) | 97 s (watchdog) | 11 s |
| + small garbage every 100 ms | 36 s | none in 150 s; RSS stayed ~700 MB | 122 s (watchdog) | 11 s |
| no callbacks | 44 s | 100 s | 98 s | 11 s |
In the second row an ordinary major GC during idle resets the watchdog clock but leaves the freed
pages pooled, so RSS doesn't come down at all within the run. In Ghost this shows as the full
post-load heap and ~2× RSS being held for ~100 s after every traffic burst on 24/26, vs ~10 s on 22.
Additional information
Disclaimer
The investigation and first drafts were done with help from an AI coding assistant; the source reading, repro runs and the numbers above were checked by hand against the V8 sources at the three Node tags. Also, the noticed effects of this issue surfaced empirically when benchmarking a Node 24/26 update for Ghost, which is currently on Node 22.
Root cause
V8 CL https://crrev.com/c/5935313 (V8 13.1) replaced the ring-buffer allocation-throughput tracker (floored at 1 B/ms) with SmoothedBytesAndDuration (no floor). A process whose sampled cppgc allocation throughput is exactly 0 — any Node process not using node:vm, the core path that allocates on cppgc — hits ComputeMutatorUtilizationImpl()'s mutator_speed == 0 → 0.0 return, so Heap::HasLowEmbedderAllocationRate() and therefore Heap::HasLowAllocationRate() are never true.
Upstream bug: https://issues.chromium.org/issues/570738027. Details, control build and traces in the repo.
Workarounds
- No
--memory-reducer*/--gc-memory-reducer*flag changes this. --optimize-for-sizeand--memory-saver-modemake the reducer run (MemorySaverModeEnabled()), at the cost of every incremental GC during load becoming a reduce GC (18 of them in the 5 s burst).- Compiling a trivial
vm.Scriptperiodically (a cppgc allocation) makes the embedder term pass; one at startup wears off after a long idle, one every 30 s survived a second burst in the repro. Works, but apps shouldn't have to.
Patch
The following patch can be applied to the V8 source code to fix the regression (should apply cleanly in both Node 24 and current main)
--- a/deps/v8/src/heap/heap.cc
+++ b/deps/v8/src/heap/heap.cc
@@ -3716,8 +3716,15 @@ bool Heap::HasLowOldGenerationAllocationRate() {
}
bool Heap::HasLowEmbedderAllocationRate() {
+ const double embedder_allocation_rate =
+ tracer()->EmbedderAllocationThroughputInBytesPerMillisecond();
+ // A sampled CppHeap allocation throughput of exactly 0 is the lowest
+ // possible allocation rate. ComputeMutatorUtilization() would otherwise
+ // treat a mutator speed of 0 as maximal allocation and the memory reducer
+ // could not start through its low-allocation condition.
+ if (embedder_allocation_rate == 0) return true;
double mu = ComputeMutatorUtilization(
- "Embedder", tracer()->EmbedderAllocationThroughputInBytesPerMillisecond(),
+ "Embedder", embedder_allocation_rate,
tracer()->EmbedderSpeedInBytesPerMillisecond());
const double kHighMutatorUtilization = 0.993;
return mu > kHighMutatorUtilization;Would a floating patch on v24.x/v26.x be acceptable ahead of the upstream fix? Happy to submit the patch as a PR if that's desired 😄
Activity
README: link nodejs/node#66564
[-]v24/v26: V8 memory reducer never sees "low alloc" when cppgc throughput is 0; post-burst heap/RSS held until the 100 s watchdog[/-][+]v24/v26: idle memory not released for ~100 s after load (V8 memory reducer regression)[/+]Fixed heap not being released after load on Node 24+
Fixed heap not being released after load on Node 24+
inoway46 commentedon Oct 7, 2026
Reproduced on v24.21.0 and v26.10.0; not reproduced on v22.23.3 (macOS x64).
inoway46 commentedon Oct 7, 2026
@acburdine Would you be willing to submit the fix and a regression test upstream to V8? Once accepted, we could consider backporting it to Node.js v24/v26.
acburdine commentedon Oct 7, 2026
Yup can do 👍🏻
acburdine commentedon Oct 7, 2026
Upstream CL with the fix and a regression test is up for review: https://chromium-review.googlesource.com/c/v8/v8/+/8521518 (crbug: https://issues.chromium.org/issues/570738027)
qmoon-atl commentedon Oct 8, 2026
I independently reached the same conclusion yesterday, then found your matching bug report when I was getting ready to submit my own, so, thanks! Let me share my observations so my effort doesn't feel totally wasted.
We saw a substantial RSS increase in a long-running Node service in production after moving from Node 22 to Node 24. I reproduced it locally on an Apple M4 Pro, comparing v22.23.2 with v24.21.0.
With
--trace-memory-reducerand--trace-mutator-utilizationenabled, the affected Node 24 main isolate reports:Conversely, Node 22 would eventually report "Memory reducer: low alloc" but Node 24 never did.
The GC traces showed that this has a large effect on retained page memory:
last-resort. Before this forced GC, the main isolate had 2,859 local pooled chunks (about 715 MiB), and the isolate-group total was 2,980. The firstreduce_memory=1collection changed the main isolate's local pool to zero and the group total to 121, attributable to the other isolate.The clue that sent me down this path was a core dump containing 2,624 aligned 256 KiB regions with
PRE_FREEDandUNREGISTEREDflags set (656 MiB), out of 5,113 aligned regions examined. The forced-GC pool-counter transition evidence confirmed that V8 was retaining hundreds of MiB of freed heap pages in its page pool.Testing our application on Node 24 with
--memory-saver-modeworked around the issue: the RSS charts were back in line with our Node 22 test.