Repository navigation
Performance degradation of Buffer allocation in NodeJS v24 #61967
Description
Activity
RajeshKumar11 commented on Feb 25, 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
confidence improvement accuracy (*) (**) (***)
buffers/buffer-creation.js n=60000 len=10 type='slow-allocUnsafe' ** -12.53 % ±7.41% ±9.86% ±12.83%
buffers/buffer-creation.js n=60000 len=1024 type='slow-allocUnsafe' 1.80 % ±5.62% ±7.49% ±9.76%
buffers/buffer-creation.js n=60000 len=16384 type='slow-allocUnsafe' *** -16.48 % ±4.84% ±6.45% ±8.41%
buffers/buffer-creation.js n=60000 len=32768 type='slow-allocUnsafe' *** -21.39 % ±5.06% ±6.75% ±8.82%
buffers/buffer-creation.js n=60000 len=4096 type='slow-allocUnsafe' 1.07 % ±3.96% ±5.27% ±6.86%
buffers/buffer-creation.js n=60000 len=65536 type='slow-allocUnsafe' *** -28.35 % ±5.53% ±7.37% ±9.63%
buffers/buffer-creation.js n=60000 len=8192 type='slow-allocUnsafe' -3.24 % ±4.21% ±5.60% ±7.29%
old=v22.20.0 new=v25.7.0
confidence improvement accuracy (*) (**) (***)
buffers/buffer-creation.js n=60000 len=10 type='slow-allocUnsafe' * -9.31 % ±9.29% ±12.37% ±16.10%
buffers/buffer-creation.js n=60000 len=1024 type='slow-allocUnsafe' -1.33 % ±5.44% ±7.24% ±9.43%
buffers/buffer-creation.js n=60000 len=16384 type='slow-allocUnsafe' *** -13.53 % ±5.48% ±7.31% ±9.58%
buffers/buffer-creation.js n=60000 len=32768 type='slow-allocUnsafe' *** -19.91 % ±5.97% ±7.97% ±10.43%
buffers/buffer-creation.js n=60000 len=4096 type='slow-allocUnsafe' -1.99 % ±4.13% ±5.49% ±7.15%
buffers/buffer-creation.js n=60000 len=65536 type='slow-allocUnsafe' *** -26.06 % ±5.86% ±7.82% ±10.22%
buffers/buffer-creation.js n=60000 len=8192 type='slow-allocUnsafe' 1.59 % ±4.65% ±6.19% ±8.06%
cc @nodejs/performance
cc @ChALkeR - any ideas?
I traced this to commit
3cdb1cd(CVE-2025-55131 security fix, merged Nov 2025), which changed howcreateUnsafeBufferworks for sizes > 64 bytes.
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
confidence improvement accuracy (*) (**) (***)
buffers/buffer-creation.js n=60000 len=10 type='slow-allocUnsafe' *** 20.79 % ±7.87% ±10.48% ±13.65%
buffers/buffer-creation.js n=60000 len=1024 type='slow-allocUnsafe' 1.85 % ±4.00% ±5.33% ±6.93%
buffers/buffer-creation.js n=60000 len=1048576 type='slow-allocUnsafe' * 7.07 % ±5.42% ±7.21% ±9.39%
buffers/buffer-creation.js n=60000 len=131072 type='slow-allocUnsafe' ** 9.14 % ±5.72% ±7.61% ±9.90%
buffers/buffer-creation.js n=60000 len=16384 type='slow-allocUnsafe' 5.91 % ±6.40% ±8.53% ±11.11%
buffers/buffer-creation.js n=60000 len=262144 type='slow-allocUnsafe' 2.97 % ±8.04% ±10.72% ±14.00%
buffers/buffer-creation.js n=60000 len=32768 type='slow-allocUnsafe' * 6.53 % ±6.10% ±8.12% ±10.56%
buffers/buffer-creation.js n=60000 len=4096 type='slow-allocUnsafe' 1.04 % ±5.52% ±7.35% ±9.57%
buffers/buffer-creation.js n=60000 len=524288 type='slow-allocUnsafe' -0.43 % ±5.18% ±6.90% ±8.98%
buffers/buffer-creation.js n=60000 len=65536 type='slow-allocUnsafe' 0.11 % ±7.39% ±9.83% ±12.80%
buffers/buffer-creation.js n=60000 len=8192 type='slow-allocUnsafe' 3.17 % ±4.34% ±5.77% ±7.52%
old=v24.12.0 new=v24.13.0
confidence improvement accuracy (*) (**) (***)
buffers/buffer-creation.js n=60000 len=10 type='slow-allocUnsafe' *** 15.62 % ±7.89% ±10.50% ±13.68%
buffers/buffer-creation.js n=60000 len=1024 type='slow-allocUnsafe' * 5.85 % ±4.58% ±6.10% ±7.95%
buffers/buffer-creation.js n=60000 len=1048576 type='slow-allocUnsafe' 2.89 % ±3.76% ±5.00% ±6.51%
buffers/buffer-creation.js n=60000 len=131072 type='slow-allocUnsafe' 0.80 % ±5.22% ±6.95% ±9.05%
buffers/buffer-creation.js n=60000 len=16384 type='slow-allocUnsafe' 2.31 % ±4.24% ±5.64% ±7.34%
buffers/buffer-creation.js n=60000 len=262144 type='slow-allocUnsafe' -2.15 % ±4.88% ±6.50% ±8.48%
buffers/buffer-creation.js n=60000 len=32768 type='slow-allocUnsafe' -0.70 % ±4.46% ±5.93% ±7.72%
buffers/buffer-creation.js n=60000 len=4096 type='slow-allocUnsafe' 0.86 % ±3.58% ±4.77% ±6.21%
buffers/buffer-creation.js n=60000 len=524288 type='slow-allocUnsafe' -2.60 % ±4.92% ±6.55% ±8.53%
buffers/buffer-creation.js n=60000 len=65536 type='slow-allocUnsafe' 2.48 % ±4.93% ±6.56% ±8.54%
buffers/buffer-creation.js n=60000 len=8192 type='slow-allocUnsafe' -2.78 % ±4.35% ±5.81% ±7.60%
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
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
cc @nodejs/v8
Sounds like benchmarking artifact, then.
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 ?
Any update?
The degradation is real, we were forced to back to NodeJS 20.12.1 in our production code.
@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 ?
Have you tried standard JavaScript like this:
const count = 500_000;
const sizes = [200]; // KB
console.log("String creation benchmark (standard JavaScript)\n");
for (let kb of sizes) {
const len = kb * 1024;
console.log(`\n=== ${kb} KB strings (${count.toLocaleString()} times) ===`);
const tests = {
"ArrayBuffer": () => new ArrayBuffer(len),
"Array": () => new Array(len + 1),
};
for (const [name, fn] of Object.entries(tests)) {
const start = Date.now();
for (let i = 0; i < count; i++) {
fn();
}
const ms = Date.now() - start;
console.log(`${name.padEnd(18)} → ${ms} ms`);
}
}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.
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.
@ChALkeR Have you been able to gain evidence that it is a bug in Node.js ?
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::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-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-55131 createUnsafeBuffer 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.
@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.
Ping @joyeecheung again, this investigation might be useful after the V8 update.
Version
v24.13.0
Platform
Subsystem
No response
What steps will reproduce the bug?
How often does it reproduce? Is there a required condition?
Every time
What is the expected behavior? Why is that the expected behavior?
Buffer allocation in NodeJS v24 should be same or better than in v20.
What do you see instead?
Degradation depended on chunk size:
Additional information
No response