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
The Buffer.poolSize default of 8 KiB has been unchanged since May 2015. Two issues with it today:
The 4 KiB cliff. The pool check is size < (Buffer.poolSize >>> 1), so with the 8 KiB default the threshold is 4 KiB and the strict inequality means a 4 KiB allocation itself bypasses the pool. The current default helps allocations from 1 B to 3999 B and abruptly stops at the page-aligned and HTTP-frame-sized boundary where many real allocations land.
Stale in 2025. Predates HTTP/2 (16 KiB-1 MiB frame sizes), modern stream chunk sizes, and ~10× growth in typical RAM. Many Buffer.allocUnsafe calls in core (fs.readFileSync for non-utf8 reads, HTTP parser, stream chunkers) sit in the 4-64 KiB range and miss the pool.
This PR raises the default to 64 KiB, extending pool coverage to allocations up to ~32 KiB.
Evidence
Benchmark setup: 8 Worker threads in one process, each looping fs.readFileSync(file) on files of various sizes, measuring throughput. Linux 6.8, glibc 2.39, i7-7700, Node main (built locally).
File size
8 KiB pool (default)
64 KiB pool
Δ
512 B
404k ops/s
431k ops/s
+7%
2 KiB
360k ops/s
367k ops/s
~
4 KiB
326k ops/s
360k ops/s
+10%
8 KiB
202k ops/s
254k ops/s
+26%
16 KiB
148k ops/s
181k ops/s
+23%
64 KiB
86k ops/s
87k ops/s
~
1 MiB
12k ops/s
12k ops/s
~
Wins where it matters (4-32 KiB), no regressions at small or large sizes.
Why workers benefit more than single-threaded
Buffer.allocUnsafe(size) for size ≥ Buffer.poolSize/2 falls through to fresh V8 ArrayBuffer allocations, which land on glibc malloc. When multiple Worker threads do this concurrently they contend on the per-mm_structmmap_lock write lock (every arena growth takes it). Confirmed by bpftrace: workers wait ~17× longer per mmap_lock acquisition than equivalent child processes on the same workload, with cumulative wait dropping ~2.5× when the pool covers the allocation size. Full investigation: https://github.com/platformatic/node-worker-mmap-lock-contention.
Single-threaded apps benefit too, just less dramatically — fewer allocator round-trips for medium-sized buffers.
Cost
+56 KiB RSS per realm at startup (one 64 KiB pool per realm; main thread + each Worker thread). On a typical app with 1 main + 4 Workers that's +280 KiB, trivial on modern hardware. The pool occupies RSS for the lifetime of any sliced Buffer that's still referenced, so peak RSS may grow modestly in apps that hold many small Buffers; same shape as today, just larger granularity.
Why 64 KiB and not larger
Tested 32 / 64 / 128 / 256 KiB; 64 KiB is the smallest pool that captures the 4-16 KiB hot zone where the current default fails. 128/256 KiB give additional wins on 16-64 KiB allocations (+64% at 16 KiB with a 256 KiB pool) but the marginal RSS cost is harder to justify as a default. Easy follow-up if there's appetite.
Pool refills at 64 KiB stay under glibc's M_MMAP_THRESHOLD (128 KiB default), so each refill uses the heap rather than triggering a fresh mmap — that boundary is part of why 64 KiB is the right ceiling for a default.
Notes
Doc updated in doc/api/buffer.md (default value + the "4 KiB" example reference).
No code change other than the constant; the existing Buffer.poolSize setter machinery is unchanged.
Filed as draft for discussion of the value (64 KiB vs 128 KiB) and to surface any concerns about RSS in memory-constrained deployments (Lambda, embedded).
The
Buffer.poolSizedefault of 8 KiB has been unchanged since May 2015. Two issues with it today:The 4 KiB cliff. The pool check is
size < (Buffer.poolSize >>> 1), so with the 8 KiB default the threshold is 4 KiB and the strict inequality means a 4 KiB allocation itself bypasses the pool. The current default helps allocations from 1 B to 3999 B and abruptly stops at the page-aligned and HTTP-frame-sized boundary where many real allocations land.Stale in 2025. Predates HTTP/2 (16 KiB-1 MiB frame sizes), modern stream chunk sizes, and ~10× growth in typical RAM. Many
Buffer.allocUnsafecalls in core (fs.readFileSyncfor non-utf8 reads, HTTP parser, stream chunkers) sit in the 4-64 KiB range and miss the pool.This PR raises the default to 64 KiB, extending pool coverage to allocations up to ~32 KiB.
Evidence
Benchmark setup: 8 Worker threads in one process, each looping
fs.readFileSync(file)on files of various sizes, measuring throughput. Linux 6.8, glibc 2.39, i7-7700, Node main (built locally).Wins where it matters (4-32 KiB), no regressions at small or large sizes.
Why workers benefit more than single-threaded
Buffer.allocUnsafe(size)forsize ≥ Buffer.poolSize/2falls through to fresh V8 ArrayBuffer allocations, which land on glibcmalloc. When multiple Worker threads do this concurrently they contend on the per-mm_structmmap_lockwrite lock (every arena growth takes it). Confirmed bybpftrace: workers wait ~17× longer permmap_lockacquisition than equivalent child processes on the same workload, with cumulative wait dropping ~2.5× when the pool covers the allocation size. Full investigation: https://github.com/platformatic/node-worker-mmap-lock-contention.Single-threaded apps benefit too, just less dramatically — fewer allocator round-trips for medium-sized buffers.
Cost
+56 KiB RSS per realm at startup (one 64 KiB pool per realm; main thread + each Worker thread). On a typical app with 1 main + 4 Workers that's +280 KiB, trivial on modern hardware. The pool occupies RSS for the lifetime of any sliced Buffer that's still referenced, so peak RSS may grow modestly in apps that hold many small Buffers; same shape as today, just larger granularity.
Why 64 KiB and not larger
Tested 32 / 64 / 128 / 256 KiB; 64 KiB is the smallest pool that captures the 4-16 KiB hot zone where the current default fails. 128/256 KiB give additional wins on 16-64 KiB allocations (+64% at 16 KiB with a 256 KiB pool) but the marginal RSS cost is harder to justify as a default. Easy follow-up if there's appetite.
Pool refills at 64 KiB stay under glibc's
M_MMAP_THRESHOLD(128 KiB default), so each refill uses the heap rather than triggering a freshmmap— that boundary is part of why 64 KiB is the right ceiling for a default.Notes
doc/api/buffer.md(default value + the "4 KiB" example reference).Buffer.poolSizesetter machinery is unchanged.cc @nodejs/buffer @nodejs/performance