What is the problem this feature will solve?
A very common pattern is to accumulate streamed input (an HTTP body, a file, a socket) and then parse it fully in memory: Buffer.concat(chunks), buf.toString(), JSON.parse(text). Today the only defenses are static byte limits (bodyLimit in Fastify, limit in raw-body, and so on). There is no supported way to ask "will this allocation fit in the memory this process actually has?", so a limit that is fine on a laptop takes a 512 MB container down.
The building blocks exist (buffer.constants.MAX_LENGTH, buffer.constants.MAX_STRING_LENGTH, process.availableMemory(), v8.getHeapStatistics().total_available_size), but the rules for combining them are non-obvious and partly undocumented. I measured the following on Linux with main (v27.0.0-pre):
Buffer allocation has two failure modes, and only one is catchable.
-
When malloc returns NULL (for example under ulimit -v, or on Linux overcommit mode 0 for a single request larger than RAM+swap) Node throws a catchable RangeError ERR_MEMORY_ALLOCATION_FAILED.
-
Under a cgroup limit the allocation succeeds and the process is SIGKILLed on first write:
$ systemd-run --user --scope -p MemoryMax=512M -p MemorySwapMax=0 node -e '
console.log(process.availableMemory() / 2**20 | 0); // 501
const b = Buffer.allocUnsafe(2**30); // succeeds
b.fill(1);' // Killed, exit 137
process.availableMemory() predicted it correctly. Nothing in the docs says to use it for this.
Decoded strings land in different memory pools depending on the input.
buf.toString('utf8') input |
Where the result lives |
| Valid UTF-8, result >= ~1M chars |
External (malloc), accounted in external_memory |
| Invalid UTF-8, any size |
V8 heap (String::NewFromUtf8 fallback in src/string_bytes.cc) |
Result < ~1M chars (EXTERN_APEX) |
V8 heap |
The same 16 MB body costs native memory when it is well formed and V8 heap when one byte is truncated. TextDecoder and stream/consumers use the same path.
The V8 heap limit is soft and its OOM is fatal. With --max-old-space-size=128 a decode that needs 267 MB of heap succeeds; the process dies with SIGABRT at the next GC that cannot get back under the limit. JSON.parse of a 60 MB array of small integers needs ~300 MB of heap and aborts. v8.getHeapStatistics().total_available_size (0.5 µs/call) is the only predictor, and it cannot see the object graph a parse will build.
The MAX_STRING_LENGTH check for latin1/ascii runs after the copy. ExternString::NewFromCopy in src/string_bytes.cc mallocs and copies the whole input, then String::NewExternalOneByte rejects it. Failing on a 512 MB input takes ~400 ms; a successful 100 MB decode takes ~46 ms. The UTF-8 paths check before doing work.
OS differences (libuv).
- Linux:
availableMemory() is cgroup v1/v2 aware and subtracts current usage (~30 µs/call).
- macOS:
availableMemory() is free + inactive + purgeable pages; constrainedMemory() only reflects RLIMIT_AS/RLIMIT_DATA, which malloc largely ignores.
- Windows:
constrainedMemory() is hard-coded to 0 (Job Object limits are invisible); availableMemory() is ullAvailPhys and ignores the commit limit. Allocation past the commit limit does fail synchronously, so at least it is catchable there.
V8 offers nothing to smooth this over: GetHeapStatistics, NearHeapLimitCallback, SetOOMErrorHandler and MemoryPressureNotification cannot reserve memory or answer "will N bytes fit".
Related: #64646 (heap_size_limit larger than the cgroup limit) is the same accounting split seen from the other side.
What is the feature you are proposing to solve the problem?
-
buffer.stringLength(buf[, encoding]), the mirror of Buffer.byteLength(). Returns the number of UTF-16 code units a decode would produce, without decoding (simdutf utf16_length_from_utf8, ~10 GB/s; trivial for latin1/ascii/hex/base64). This lets a parser check against MAX_STRING_LENGTH and compute the 2 * length byte bound before doing any work. Whether the result can be one-byte is not needed for an upper bound.
-
Check MAX_STRING_LENGTH before copying in the latin1/ascii decode path in src/string_bytes.cc. Independent, small fix.
-
Document a memory-budgeting recipe in doc/api/buffer.md and doc/api/process.md rather than adding a boolean canAllocate(). A boolean would have to hide the headroom policy and the heap/native split, and would be wrong on macOS and Windows where the probes are weaker. The recipe:
const { constants: { MAX_LENGTH, MAX_STRING_LENGTH }, stringLength } = require('node:buffer');
const RESERVE = 64 * 2 ** 20; // headroom for the rest of the process
// (a) Buffer.alloc(n), (d) Buffer.concat(list) with n = total length
// (peak is 2 * n while the sources are alive)
function fitsNative(n) {
return n <= MAX_LENGTH && n + RESERVE <= process.availableMemory();
}
// (b) a string of `units` UTF-16 code units built in JS (concat, JSON.stringify, ...)
function fitsHeap(units) {
return units <= MAX_STRING_LENGTH &&
units * 2 + RESERVE <= v8.getHeapStatistics().total_available_size;
}
// (c) buf.toString(): large valid UTF-8 is native, everything else is heap
function canDecode(buf) {
const units = stringLength(buf, 'utf8');
return units <= MAX_STRING_LENGTH && fitsNative(units * 2) && fitsHeap(units);
}
Plus an explicit warning that nothing can bound JSON.parse, and that the V8 limit is enforced at GC time, not at allocation.
-
libuv follow-up: read Job Object memory limits in uv_get_constrained_memory() on Windows, which is the one gap that makes the recipe wrong in Windows containers.
What alternatives have you considered?
buffer.canAllocate(bytes) / Buffer.canAlloc(): rejected for the reasons in (3). It cannot be made truthful for heap strings and hides OS-specific weakness.
- A "reserve" API that pre-touches pages: would defeat lazy commit and cost a full write of the buffer.
- Leaving it to userland: userland already does static limits; the information needed for dynamic ones is only partly exposed (
stringLength does not exist) and the pool split is not documented anywhere.
A portable probe script that reproduces each measurement above (runs the risky steps in child processes) is available on request; I will attach results from macOS and Windows.
What is the problem this feature will solve?
A very common pattern is to accumulate streamed input (an HTTP body, a file, a socket) and then parse it fully in memory:
Buffer.concat(chunks),buf.toString(),JSON.parse(text). Today the only defenses are static byte limits (bodyLimitin Fastify,limitinraw-body, and so on). There is no supported way to ask "will this allocation fit in the memory this process actually has?", so a limit that is fine on a laptop takes a 512 MB container down.The building blocks exist (
buffer.constants.MAX_LENGTH,buffer.constants.MAX_STRING_LENGTH,process.availableMemory(),v8.getHeapStatistics().total_available_size), but the rules for combining them are non-obvious and partly undocumented. I measured the following on Linux withmain(v27.0.0-pre):Buffer allocation has two failure modes, and only one is catchable.
When
mallocreturnsNULL(for example underulimit -v, or on Linux overcommit mode 0 for a single request larger than RAM+swap) Node throws a catchableRangeErrorERR_MEMORY_ALLOCATION_FAILED.Under a cgroup limit the allocation succeeds and the process is
SIGKILLed on first write:process.availableMemory()predicted it correctly. Nothing in the docs says to use it for this.Decoded strings land in different memory pools depending on the input.
buf.toString('utf8')inputmalloc), accounted inexternal_memoryString::NewFromUtf8fallback insrc/string_bytes.cc)EXTERN_APEX)The same 16 MB body costs native memory when it is well formed and V8 heap when one byte is truncated.
TextDecoderandstream/consumersuse the same path.The V8 heap limit is soft and its OOM is fatal. With
--max-old-space-size=128a decode that needs 267 MB of heap succeeds; the process dies withSIGABRTat the next GC that cannot get back under the limit.JSON.parseof a 60 MB array of small integers needs ~300 MB of heap and aborts.v8.getHeapStatistics().total_available_size(0.5 µs/call) is the only predictor, and it cannot see the object graph a parse will build.The
MAX_STRING_LENGTHcheck forlatin1/asciiruns after the copy.ExternString::NewFromCopyinsrc/string_bytes.ccmallocs and copies the whole input, thenString::NewExternalOneByterejects it. Failing on a 512 MB input takes ~400 ms; a successful 100 MB decode takes ~46 ms. The UTF-8 paths check before doing work.OS differences (libuv).
availableMemory()is cgroup v1/v2 aware and subtracts current usage (~30 µs/call).availableMemory()is free + inactive + purgeable pages;constrainedMemory()only reflectsRLIMIT_AS/RLIMIT_DATA, whichmalloclargely ignores.constrainedMemory()is hard-coded to 0 (Job Object limits are invisible);availableMemory()isullAvailPhysand ignores the commit limit. Allocation past the commit limit does fail synchronously, so at least it is catchable there.V8 offers nothing to smooth this over:
GetHeapStatistics,NearHeapLimitCallback,SetOOMErrorHandlerandMemoryPressureNotificationcannot reserve memory or answer "will N bytes fit".Related: #64646 (
heap_size_limitlarger than the cgroup limit) is the same accounting split seen from the other side.What is the feature you are proposing to solve the problem?
buffer.stringLength(buf[, encoding]), the mirror ofBuffer.byteLength(). Returns the number of UTF-16 code units a decode would produce, without decoding (simdutfutf16_length_from_utf8, ~10 GB/s; trivial forlatin1/ascii/hex/base64). This lets a parser check againstMAX_STRING_LENGTHand compute the2 * lengthbyte bound before doing any work. Whether the result can be one-byte is not needed for an upper bound.Check
MAX_STRING_LENGTHbefore copying in thelatin1/asciidecode path insrc/string_bytes.cc. Independent, small fix.Document a memory-budgeting recipe in
doc/api/buffer.mdanddoc/api/process.mdrather than adding a booleancanAllocate(). A boolean would have to hide the headroom policy and the heap/native split, and would be wrong on macOS and Windows where the probes are weaker. The recipe:Plus an explicit warning that nothing can bound
JSON.parse, and that the V8 limit is enforced at GC time, not at allocation.libuv follow-up: read Job Object memory limits in
uv_get_constrained_memory()on Windows, which is the one gap that makes the recipe wrong in Windows containers.What alternatives have you considered?
buffer.canAllocate(bytes)/Buffer.canAlloc(): rejected for the reasons in (3). It cannot be made truthful for heap strings and hides OS-specific weakness.stringLengthdoes not exist) and the pool split is not documented anywhere.A portable probe script that reproduces each measurement above (runs the risky steps in child processes) is available on request; I will attach results from macOS and Windows.