Skip to content

Performance degradation of Buffer allocation in NodeJS v24 #61967

Description

@damanis

Version

v24.13.0

Platform

Linux ubuntu24 6.14.0-37-generic #37~24.04.1-Ubuntu SMP PREEMPT_DYNAMIC Thu Nov 20 10:25:38 UTC 2 x86_64 x86_64 x86_64 GNU/Linux

Subsystem

No response

What steps will reproduce the bug?

console.log(process.version);
let table = [], count = 1e6;
for (let chunk_size of [10, 20, 50, 100, 200].map(v=>v*1024))
{
    let ts = Date.now();
    for (let i = 0; i<count; i++)
        Buffer.allocUnsafe(chunk_size);
    let te = Date.now();
    table.push({chunk_size, ms: te-ts});
}
console.table(table, ['chunk_size', 'ms']);

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:

v20.12.1
┌─────────┬────────────┬──────┐
│ (index) │ chunk_size │ ms   │
├─────────┼────────────┼──────┤
│ 0       │ 10240      │ 1044 │
│ 1       │ 20480      │ 1110 │
│ 2       │ 51200      │ 1230 │
│ 3       │ 102400     │ 1339 │
│ 4       │ 204800     │ 1969 │
└─────────┴────────────┴──────┘


v24.13.0
┌─────────┬────────────┬──────┐
│ (index) │ chunk_size │ ms   │
├─────────┼────────────┼──────┤
│ 0       │ 10240      │ 996  │
│ 1       │ 20480      │ 1223 │
│ 2       │ 51200      │ 1387 │
│ 3       │ 102400     │ 2077 │
│ 4       │ 204800     │ 3349 │
└─────────┴────────────┴──────┘

Additional information

No response

Activity

RajeshKumar11 commented on Feb 25, 2026

@RajeshKumar11

Renegade334 commented on Feb 26, 2026

@Renegade334
Member

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?

Renegade334 commented on Feb 26, 2026

@Renegade334
Member

I traced this to commit 3cdb1cd (CVE-2025-55131 security fix, merged Nov 2025), which changed how createUnsafeBuffer works 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%
added
bufferIssues and PRs related to the buffer subsystem.
performanceIssues and PRs related to the performance of Node.js.
on Feb 26, 2026

ChALkeR commented on Feb 26, 2026

@ChALkeR
Member

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).

ChALkeR commented on Feb 26, 2026

@ChALkeR
Member

@Renegade334

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

ChALkeR commented on Mar 2, 2026

@ChALkeR
Member

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 commented on Mar 2, 2026

@ChALkeR
Member

cc @nodejs/v8

Renegade334 commented on Mar 2, 2026

@Renegade334
Member

Sounds like benchmarking artifact, then.

ChALkeR commented on Mar 2, 2026

@ChALkeR
Member

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 commented on May 13, 2026

@damanis
Author

Any update?
The degradation is real, we were forced to back to NodeJS 20.12.1 in our production code.

lemire commented on May 13, 2026

@lemire
Member

@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.

lemire commented on May 13, 2026

@lemire
Member

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 commented on May 13, 2026

@lemire
Member

@ChALkeR Have you been able to gain evidence that it is a bug in Node.js ?

eliau2005 commented on Sep 3, 2026

@eliau2005
Contributor

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:

  1. 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.
  2. 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-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.

eliau2005 commented on Sep 3, 2026

@eliau2005
Contributor

@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.

MayaLekova commented on Sep 15, 2026

@MayaLekova
Contributor

Ping @joyeecheung again, this investigation might be useful after the V8 update.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bufferIssues and PRs related to the buffer subsystem.performanceIssues and PRs related to the performance of Node.js.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions