You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Performance degradation of Buffer allocation in NodeJS v24 #61967
There is still a hit to the buffer allocation benchmarks in v24.x and later compared with previous versions, even with #60423 addressed. I'm seeing very similar figures with the plain Uint8Array constructor (indeed, new Uint8Array(n) becomes similarly slower between v23 and v24), suggesting that this might be a V8 issue?
which changed how createUnsafeBuffer works for sizes > 64 bytes.
No, it didn't change that. Storage allocs <=64 bypassed ArrayBuffer creation and malloc both with and without that patch.
They are stored in heap in v8, and our allocator wasn't even called for them, and .buffer is lazy-initialized for them and was hence zero-filled (as was allocated outside of flagged region).
becomes similarly slower between v23 and v24), suggesting that this might be a V8 issue?
I suggest benchmarking v8 cli on corresponding versions for that
Node.js has its own allocator and nontrivial logic around that, so hard to tell without confiming that the regression exists in v8
Also, if buffers are retained until timestamping (so that gc can't collect them), the effect reverses - new allocs are faster.
GC on those new allocs is slower though
Was something flipped in the underlying memory allocation logic on Node.js side?
@damanis I find no evidence in this thread that there is an issue with Node.js.
At a glance, this is a garbage collection stress test.
Node.js relies on v8 for JavaScript execution. Of course, different versions of Node.js use different versions of v8. Thus, JavaScript performance will vary (both positively and negatively depending on the benchmark) with the various versions.
The Buffer.allocUnsafe function is indeed provided by Node.js, not v8. But is this relevant here ?
In my tests, the more recent versions of Node are slower on this benchmark.
But it does not mean that there is a bug, and especially not a bug in Node.js.
There are engineering tradeoffs when it comes to garbage collection.
Note that I am not ruling out a bug in Node.js. What I am saying is that you have not provided sufficient evidence to establish that there is such a bug.
The degradation is real, we were forced to back to NodeJS 20.12.1 in our production code.
It is certainly possible to be bottlenecked by garbage collection... But it is possible to reduce the stress. A buffer instance can be reused.
I spent some time bisecting this and I think I can answer the "Node or V8?" question with a specific commit. Short version: it is a V8 heap-heuristic change, the Node-side Buffer/stream code is not involved, and V8 has already changed the relevant behaviour upstream but no Node release carries that yet. #63863 appears to be the same bug.
Introducing change
V8 commit 789b37d5db1f "[heap] Increase new space capacity on desktop to 32MB" (Nov 2024, Bug: 351843812) changed the default of scavenger_max_new_space_capacity_mb from 8 to 32 on non-Android. It reached Node with the V8 13.6 update (c7964bc02bd, #58070), i.e. v24.0.0. Nightlies confirm the flip: v24.0.0-nightly20250501 (V8 13.0, default 8) is fast, v24.0.0-nightly20250506 (V8 13.6, default 32) is slow. v22/v23 (V8 12.4/12.9) are unaffected.
Mechanism
V8 has two independent triggers for external (ArrayBuffer backing-store) memory:
Heap::AllocateExternalBackingStore runs a cheap Scavenge when young ArrayBuffer bytes reach 2 * DefaultMaxSemiSpaceSize(). Node is built without pointer compression, so DefaultMaxSemiSpaceSize() is flag * 2 MB: 16 MB before, 64 MB after → the trigger moved from 32 MB to 128 MB.
Heap::HandleExternalMemoryInterrupt (ReportExternalMemoryPressure in 12.9) starts incremental marking when total external memory exceeds a fixed soft limit of kExternalAllocationSoftLimit = 64 MB above the low-water mark since the last mark-compact.
With the old default the Scavenge always fired first (32 MB < 64 MB), freed the dead buffers, and the soft limit was never reached. With the new default the 64 MB soft limit is reached first, so every ~64 MB of short-lived buffers starts a Mark-Compact instead of a Scavenge. --trace-gc on the #63863 benchmark (n=2e4): v23.11.1 → 78 Scavenges (72 with reason "external memory pressure"), 2 Mark-Compacts; v24.16.0 → 6 Scavenges, 20 Mark-Compacts, and --trace-incremental-marking shows marking started 19× with reason "external memory pressure".
Bidirectional check (5-run medians, same machine/batch, ops/s):
binary
--scavenger-max-new-space-capacity-mb
ops/s
Scavenge / MC
v23.11.1
default (8)
55.2k
78 / 2
v23.11.1
=32
22.9k
MC-dominated
v24.16.0
default (32)
21.0k
6 / 20
v24.16.0
=8
58.6k
75 / 3
Forcing the one flag reproduces the regression on v23 and removes it on v24. The effect reproduces with plain new ArrayBuffer(126000) / new Uint8Array loops (no Buffer or stream code), does not appear in JS-only workloads, and shows up in event-loop-draining code too (fs.createReadStream with a 128 KiB highWaterMark: −16 %; a Readable→PassThrough pipeline of 128 KiB chunks: −45 %). Diffing lib/buffer.js, lib/internal/buffer.js, lib/internal/streams/readable.js, src/node_buffer.cc and the allocator between v23.11.1 and v24.16.0 shows no change in allocation count, size or kind, so I don't think the CVE-2025-55131createUnsafeBuffer change is related (it is in v22.22.3 as well).
Why --external-memory-accounted-in-global-limit helps: that flag skips the 64 MB soft-limit path in HandleExternalMemoryInterrupt (external memory is folded into the global allocation limit instead), so the young-generation Scavenge trigger is reachable again. --max-semi-space-size has no effect at any value, because the trigger reads the default constant, not the configured semi-space.
Upstream status: V8 enabled external_memory_accounted_in_global_limit by default in March 2026 (6a5039d9db92, Bug: 361124432) and then removed the flag (cf511a65adc5). The pending V8 14.9/15.2 updates (#64784, #65161) contain that, so main should get the new behaviour with the next V8 bump. v24.x (V8 13.6) and v26.x (V8 14.6) still have the flag off, and the flag is removed in 14.1+ so --scavenger-max-new-space-capacity-mb is only a workaround on v24. One caveat from testing the accounting flag on the shipped binaries: the flag-on code paths in 13.6/14.6 predate later upstream fixes (external growing-factor cap 772da5fa4155, global-limit floor 8f876708cf92), and with just the flag forced I measured higher peak external memory for medium-lived buffers on v24 and repeated Mark-Compacts when live external memory exceeds ~2× --max-old-space-size, so it does not look like a bare flag flip is enough on the release lines.
Given that, what would maintainers prefer for v24.x/v26.x: floating the upstream flip plus its companion commits as deps/v8 backports, waiting for the V8 updates, or documenting the flags as workarounds? I'm happy to put together whichever option is preferred and to add the #63863 benchmark to benchmark/streams/ in the meantime. Full notes and reproduction scripts are available if useful.
Reacted by Vinicius Lourenço, Maya Lekova and Eliau Elkouby
@ChALkeR I believe the V8-level bisect and the bidirectional scavenger_max_new_space_capacity_mb experiment now provide the evidence that was missing earlier. The remaining question is how Node would prefer to handle the affected release lines.
Activity
RajeshKumar11 commentedon Feb 25, 2026
benchmark: add large buffer sizes to buffer-creation
Renegade334 commentedon Feb 26, 2026
There is still a hit to the buffer allocation benchmarks in v24.x and later compared with previous versions, even with #60423 addressed. I'm seeing very similar figures with the plain Uint8Array constructor (indeed,
new Uint8Array(n)becomes similarly slower between v23 and v24), suggesting that this might be a V8 issue?old=v22.20.0 new=v24.14.0
old=v22.20.0 new=v25.7.0
cc @nodejs/performance
cc @ChALkeR - any ideas?
Renegade334 commentedon Feb 26, 2026
No, there is no negative performance difference from the allocUnsafe patch. Please don't cut and paste LLM output without fact-checking it.
old=v20.19.0 new=v20.20.0
old=v24.12.0 new=v24.13.0
ChALkeR commentedon Feb 26, 2026
No, it didn't change that. Storage allocs <=64 bypassed ArrayBuffer creation and malloc both with and without that patch.
They are stored in heap in v8, and our allocator wasn't even called for them, and
.bufferis lazy-initialized for them and was hence zero-filled (as was allocated outside of flagged region).ChALkeR commentedon Feb 26, 2026
@Renegade334
I suggest benchmarking v8 cli on corresponding versions for that
Node.js has its own allocator and nontrivial logic around that, so hard to tell without confiming that the regression exists in v8
ChALkeR commentedon Mar 2, 2026
This is between 23.11.1 (v8: '12.9.202.28-node.14') and 24.0.0 (v8: '13.6.233.8-node.10')
As @Renegade334 said, this is also observable on Uint8Arrays
The difference is GC behavior
In Node.js 23.11.1, it was only Scavenge
In Node.js 24.0.0, it's incremental Mark-Compact
ChALkeR commentedon Mar 2, 2026
cc @nodejs/v8
Renegade334 commentedon Mar 2, 2026
Sounds like benchmarking artifact, then.
ChALkeR commentedon Mar 2, 2026
Also, if buffers are retained until timestamping (so that gc can't collect them), the effect reverses - new allocs are faster.
GC on those new allocs is slower though
Was something flipped in the underlying memory allocation logic on Node.js side?
perhaps cc @joyeecheung ?
damanis commentedon May 13, 2026
Any update?
The degradation is real, we were forced to back to NodeJS 20.12.1 in our production code.
lemire commentedon May 13, 2026
@damanis I find no evidence in this thread that there is an issue with Node.js.
At a glance, this is a garbage collection stress test.
Node.js relies on v8 for JavaScript execution. Of course, different versions of Node.js use different versions of v8. Thus, JavaScript performance will vary (both positively and negatively depending on the benchmark) with the various versions.
The
Buffer.allocUnsafefunction is indeed provided by Node.js, not v8. But is this relevant here ?Have you tried standard JavaScript like this:
In my tests, the more recent versions of Node are slower on this benchmark.
But it does not mean that there is a bug, and especially not a bug in Node.js.
There are engineering tradeoffs when it comes to garbage collection.
Note that I am not ruling out a bug in Node.js. What I am saying is that you have not provided sufficient evidence to establish that there is such a bug.
It is certainly possible to be bottlenecked by garbage collection... But it is possible to reduce the stress. A buffer instance can be reused.
lemire commentedon May 13, 2026
My proposal at this time is to close this issue unless someone can provide hard evidence that the issue is indeed with Node.js and not with v8.
lemire commentedon May 13, 2026
@ChALkeR Have you been able to gain evidence that it is a bug in Node.js ?
eliau2005 commentedon Sep 3, 2026
I spent some time bisecting this and I think I can answer the "Node or V8?" question with a specific commit. Short version: it is a V8 heap-heuristic change, the Node-side Buffer/stream code is not involved, and V8 has already changed the relevant behaviour upstream but no Node release carries that yet. #63863 appears to be the same bug.
Introducing change
V8 commit
789b37d5db1f"[heap] Increase new space capacity on desktop to 32MB" (Nov 2024,Bug: 351843812) changed the default ofscavenger_max_new_space_capacity_mbfrom 8 to 32 on non-Android. It reached Node with the V8 13.6 update (c7964bc02bd, #58070), i.e. v24.0.0. Nightlies confirm the flip:v24.0.0-nightly20250501(V8 13.0, default 8) is fast,v24.0.0-nightly20250506(V8 13.6, default 32) is slow. v22/v23 (V8 12.4/12.9) are unaffected.Mechanism
V8 has two independent triggers for external (ArrayBuffer backing-store) memory:
Heap::AllocateExternalBackingStoreruns a cheap Scavenge when young ArrayBuffer bytes reach2 * DefaultMaxSemiSpaceSize(). Node is built without pointer compression, soDefaultMaxSemiSpaceSize()isflag * 2 MB: 16 MB before, 64 MB after → the trigger moved from 32 MB to 128 MB.Heap::HandleExternalMemoryInterrupt(ReportExternalMemoryPressurein 12.9) starts incremental marking when total external memory exceeds a fixed soft limit ofkExternalAllocationSoftLimit= 64 MB above the low-water mark since the last mark-compact.With the old default the Scavenge always fired first (32 MB < 64 MB), freed the dead buffers, and the soft limit was never reached. With the new default the 64 MB soft limit is reached first, so every ~64 MB of short-lived buffers starts a Mark-Compact instead of a Scavenge.
--trace-gcon the #63863 benchmark (n=2e4): v23.11.1 → 78 Scavenges (72 with reason "external memory pressure"), 2 Mark-Compacts; v24.16.0 → 6 Scavenges, 20 Mark-Compacts, and--trace-incremental-markingshows marking started 19× with reason "external memory pressure".Bidirectional check (5-run medians, same machine/batch, ops/s):
--scavenger-max-new-space-capacity-mb=32=8Forcing the one flag reproduces the regression on v23 and removes it on v24. The effect reproduces with plain
new ArrayBuffer(126000)/new Uint8Arrayloops (no Buffer or stream code), does not appear in JS-only workloads, and shows up in event-loop-draining code too (fs.createReadStreamwith a 128 KiB highWaterMark: −16 %; aReadable→PassThroughpipeline of 128 KiB chunks: −45 %). Diffinglib/buffer.js,lib/internal/buffer.js,lib/internal/streams/readable.js,src/node_buffer.ccand the allocator between v23.11.1 and v24.16.0 shows no change in allocation count, size or kind, so I don't think the CVE-2025-55131createUnsafeBufferchange is related (it is in v22.22.3 as well).Why
--external-memory-accounted-in-global-limithelps: that flag skips the 64 MB soft-limit path inHandleExternalMemoryInterrupt(external memory is folded into the global allocation limit instead), so the young-generation Scavenge trigger is reachable again.--max-semi-space-sizehas no effect at any value, because the trigger reads the default constant, not the configured semi-space.Upstream status: V8 enabled
external_memory_accounted_in_global_limitby default in March 2026 (6a5039d9db92,Bug: 361124432) and then removed the flag (cf511a65adc5). The pending V8 14.9/15.2 updates (#64784, #65161) contain that, somainshould get the new behaviour with the next V8 bump. v24.x (V8 13.6) and v26.x (V8 14.6) still have the flag off, and the flag is removed in 14.1+ so--scavenger-max-new-space-capacity-mbis only a workaround on v24. One caveat from testing the accounting flag on the shipped binaries: the flag-on code paths in 13.6/14.6 predate later upstream fixes (external growing-factor cap772da5fa4155, global-limit floor8f876708cf92), and with just the flag forced I measured higher peak external memory for medium-lived buffers on v24 and repeated Mark-Compacts when live external memory exceeds ~2×--max-old-space-size, so it does not look like a bare flag flip is enough on the release lines.Given that, what would maintainers prefer for v24.x/v26.x: floating the upstream flip plus its companion commits as
deps/v8backports, waiting for the V8 updates, or documenting the flags as workarounds? I'm happy to put together whichever option is preferred and to add the #63863 benchmark tobenchmark/streams/in the meantime. Full notes and reproduction scripts are available if useful.eliau2005 commentedon Sep 3, 2026
@ChALkeR I believe the V8-level bisect and the bidirectional
scavenger_max_new_space_capacity_mbexperiment now provide the evidence that was missing earlier. The remaining question is how Node would prefer to handle the affected release lines.MayaLekova commentedon Sep 15, 2026
Ping @joyeecheung again, this investigation might be useful after the V8 update.
Work around Node 24 scavenger memory regression