Skip to content

Buffer.poolSize default increase (24.18.0, #63597) increases steady-state memory retention under long-running HTTP server load #67235

Open

Description

@chinrichs-godaddy

Version

24.18.0+

Platform

linux arm64 (Debian 13/trixie) in production; the mechanism itself should be platform-independent

Subsystem

buffer, http

What steps will reproduce the bug?

We run a long-lived Node.js HTTP/HTTPS proxy server (ECS/EC2 containers, clustered
worker processes) handling sustained production traffic: many concurrent inbound HTTP/HTTPS
connections, with the server itself making outbound HTTP/HTTPS requests to several backend
services per inbound request (reverse-proxy pattern).

After upgrading from a pinned Node 24.13.1 to 24.22.2, our production hosts began repeatedly climbing in memory usage (external/
arrayBuffers from process.memoryUsage(), not heap) until an internal health-check library
marked the process unhealthy at a configured system-memory threshold, at which point the
orchestrator (ECS) recycled the task. This happened on a consistent ~35-45 minute cycle under
steady traffic.

Production evidence (primary finding)

We ran three side-by-side production comparisons, same application code, same OS image/AMI, only
the Node binary (or one allocator parameter) changed:

  1. Node 24.22.1, default Buffer.poolSize (65536 / 64 KiB): external/arrayBuffers
    climbs steadily until the host crosses our 85% system-memory threshold and is recycled, on a
    repeatable ~35-45 minute cycle.
  2. Node 24.13.0 (pre-24.18.0, default Buffer.poolSize = 8192 / 8 KiB): memory climbs
    gently at startup and plateaus; holds flat for hours under the same traffic.
  3. Node 24.22.1 with Buffer.poolSize explicitly forced back to 8192 at process startup
    (all other code/config/CVE patches unchanged from scenario 1)
    : same stable, plateauing shape
    as scenario 2 -- held flat for over an hour, well past the point scenario 1 would have
    crashed.

Forcing Buffer.poolSize back to its pre-24.18.0 default on an otherwise-unmodified, fully
patched 24.22.1 binary reproduces the old, stable memory behavior. This isolates the
Buffer.poolSize default change (#63597, which raised it from 8 KiB to 64 KiB in 24.18.0) as the
variable responsible.

Minimal synthetic repro (directional corroboration, smaller scale)

Attached (repro.js): a standalone script with no dependencies that allocates many small,
allocUnsafe-backed buffers with a mix of short lifetimes (~20-30ms, mimicking fast requests)
and longer lifetimes (~5-6s, mimicking slow requests/keep-alive) sharing the pool. It does not
reproduce the full magnitude or the unbounded climb-to-crash shape we see in production over 90
seconds -- that takes tens of minutes of our real traffic volume -- but it does show a
consistent, repeatable difference in steady-state memory retention from the pool size alone:

$ node repro.js             # default poolSize on 24.22.1 (65536)
--- summary: poolSize=65536 steady-state (t>=30s) avg RSS=228.8MB max RSS=257.3MB avg external=34.8MB ---

$ node repro.js 8192        # poolSize forced to 8192 (pre-24.18.0 default)
--- summary: poolSize=8192 steady-state (t>=30s) avg RSS=191.9MB max RSS=211.4MB avg external=21.9MB ---

Repeated across two trials each: ~228-235MB avg RSS at poolSize=65536 vs. ~192MB at
poolSize=8192 (roughly 20% higher), and external averaging ~35MB vs. ~22MB (roughly 60%
higher) -- a consistent, directional difference, though smaller in absolute terms than our
production crash cycle, which runs far longer and at far higher concurrency. Notably,
heapUsed was not lower with the larger pool (it was slightly higher in our trials),
indicating this difference comes specifically from the buffer pool mechanism, not heap
variance between runs.

Repro file:

repro.js

How often does it reproduce? Is there a required condition?

100% reproducible in production under our traffic pattern. The attached synthetic repro
reproduces the directional steady-state RSS/external difference consistently across repeated
runs, though at a much smaller scale/duration than production.

What is the expected behavior? Why is that the expected behavior?

Sustained HTTP/HTTPS server memory usage (specifically external/arrayBuffers, i.e.
off-heap buffer memory) should stabilize/plateau under steady-state traffic, consistent with
pre-24.18.0 behavior, rather than growing until the process must be killed and restarted.

What do you see instead?

In production: external/arrayBuffers grows roughly linearly under sustained traffic and
does not appear to be reclaimed by GC until the process is killed by our own
health-check/orchestration layer. heapUsed stays flat and low throughout -- this is not a
heap leak. In the synthetic repro: steady-state RSS and external are both substantially
higher with the 24.18.0+ default pool size than with the pre-24.18.0 default, for an identical
workload.

Suspected mechanism

Buffer.allocUnsafe() for sizes under half of Buffer.poolSize slices a new Buffer view out
of one shared, pre-allocated pool ArrayBuffer, rather than allocating a dedicated backing
store per call. The entire pool block cannot be garbage-collected until every slice taken from
it is also unreachable -- a single long-lived small buffer (e.g. sitting in a socket's read
buffer, a cached header fragment, a string mid-decode) pins the whole block alive.

#63597 raised Buffer.poolSize's default from 8 KiB to 64 KiB specifically to improve
HTTP-parser/stream throughput for medium-sized reads (cited benchmarks: 10-26% improvement). That
PR's own discussion acknowledges a smaller, different memory cost ("+56 KiB RSS per realm at
startup") but does not appear to address the worst-case retention multiplier this introduces
under sustained load with mixed-lifetime allocations: raising the pool size from 8 KiB to 64 KiB
raises the amount of memory a single retained slice can pin alive by 8x. Under enough concurrent
in-flight allocations with a mix of lifetimes (exactly the pattern of a busy HTTP server --
most requests fast, some slow/keep-alive, sharing pool blocks), this appears to produce
materially worse steady-state memory retention than before, even though no single allocation is
individually larger or longer-lived than before.

We have not found an existing issue describing this interaction; the closest related report we
found (#60423, re: allocUnsafe zero-fill behavior) is about a different problem (unintended
zero-filling / performance of allocUnsafe itself, not pool retention under sustained load).

Additional information

  • Buffer.poolSize can be overridden at runtime (require('buffer').Buffer.poolSize = 8192)
    as a workaround, which is what we've deployed as an interim production mitigation. We would
    prefer not to carry this override indefinitely, since it forgoes the throughput improvements
    buffer: increase Buffer.poolSize default to 64 KiB #63597 was intended to provide.
  • We're happy to provide more detail from our production CloudWatch data (memory curves, GC
    pause stats via PerformanceObserver({ type: 'gc' })).
  • Open to suggestions on whether this is better characterized as a GC heuristic issue (V8 not
    prioritizing collection of retained ArrayBuffer-backed pool blocks under external memory
    pressure) versus a pool-sizing tradeoff that should be reconsidered or made more conservative
    for long-running server workloads specifically (vs. short-lived CLI/script workloads where the
    throughput win applies more cleanly).

Activity

  1. chinrichs-godaddy commented on Nov 4, 2026

    @chinrichs-godaddy
    Author

    Independent corroboration from a different codebase/workload: vercel/next.js#99848 reports the same mechanism hitting Next.js's default "use cache" LRU handler. Their small, long-lived buffers from Buffer.concat() pin entire pool slabs alive, so actual memory (arrayBuffers) vastly exceeds what the LRU's own byte-accounting expects — in their test (10,000 entries, ~1 KB each, ~10 MB counted):

    Node / Buffer.poolSize Counted (LRU) Actual arrayBuffers
    Node 24, 8 KiB (pre-#63597) 9.9 MB 42 MB
    Node 26, 64 KiB (post-#63597) 9.9 MB 221 MB

    Same slab-pinning mechanism we described above (small long-lived allocation retains the whole oversized pool block), same directional multiplier from the larger default, just surfacing through Buffer.concat()-backed cache entries instead of our HTTP/TLS request path. Their proposed fix — copying small, long-lived buffers into unpooled storage via Buffer.allocUnsafeSlow() rather than letting them share the pool — is a more targeted mitigation than our blanket poolSize override, and may be worth considering as a potential broader fix (e.g. for pool slices expected to outlive their allocating context) rather than only reverting the default.

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions