Module A-6·23 min read

Processing gigabytes of transactional logs without V8 heap saturation — Buffer internals, Transform stream pipelines, and zero-copy ingestion.

JJS
Written by Jatin Jain Saraf · Senior Software Engineer

Module 5 — Native Streams & Off-Heap Buffer Storage

What this module covers: Processing gigabytes of blockchain transaction logs or UPI settlement files without crashing the V8 runtime requires understanding the boundary between V8-managed memory and memory that lives completely outside it. Buffer in Node.js allocates from the OS directly, bypassing V8's heap and garbage collector entirely. Custom Transform streams process that memory in chunks, keeping your heap stable regardless of input size. This module covers the precise memory model for off-heap storage, the correct implementation of Transform streams for production ingestion pipelines, and streaming pipeline composition with pipeline().


The Memory Boundary: V8 Heap vs Off-Heap

In Module 1 you learned that V8 manages its own heap — New Space for short-lived objects, Old Space for long-lived ones. All standard JavaScript objects live here: arrays, plain objects, strings, closures.

Node.js adds a second memory region: off-heap memory — memory allocated directly from the OS via malloc, completely outside V8's control. V8 has no visibility into it, no GC responsibility for it, and cannot trigger GC based on its size.

This distinction is critical for ingestion pipelines:

text

When you process a 500MB blockchain block file with Buffer, V8's heap stays flat. The data never touches the GC. The only heap allocation is a small Buffer JavaScript object (~100 bytes) that wraps the off-heap memory pointer.

The shipping-container analogy: Off-heap Buffers are like a shipping-container yard next to the V8 warehouse — the crane pointing at a container (the JS Buffer object) is small enough to sit on a warehouse shelf, but the container itself never enters the warehouse and therefore never triggers a warehouse-wide inventory audit (GC).


Buffer Internals: Three Allocation Methods

Buffer.alloc(size)

javascript

Allocates size bytes of off-heap memory and zeros it. Safe for security-sensitive operations where you cannot allow previous memory contents to be readable. Slower due to the memset.

Use when: the buffer will be read before being fully written (zero prevents reading stale data), or the data is security-sensitive.

Buffer.allocUnsafe(size)

javascript

Allocates size bytes of off-heap memory without zeroing. The memory may contain previous data from other allocations. Faster — no memset.

The pooling threshold is half the pool size (Buffer.poolSize >>> 1) — with the default 8KB pool, that's sizes ≤ 4KB. Those allocations are sliced from a shared 8KB pooled slab that Node pre-allocates. Multiple small Buffer.allocUnsafe calls share the same underlying memory slab, reducing syscall overhead.

Use when: you will immediately write to the buffer before reading, and the data is not security-sensitive. Most ingestion pipelines fall into this category — you write parsed transaction data into the buffer immediately.

Buffer.allocUnsafeSlow(size)

javascript

Allocates size bytes of off-heap memory bypassing the 8KB slab pool. Each call requests memory directly from the OS (via malloc). Slower than allocUnsafe for small buffers, but avoids fragmentation for large allocations.

Use when: allocating large buffers (> 4KB) that will live for a long time. Avoids consuming the shared slab, which would be a waste for large chunks.

javascript

The Hard Ceiling: buffer.constants.MAX_LENGTH

Every allocation method above is still bounded by a hard maximum for a single Buffer instance:

javascript

On modern 64-bit Node, MAX_LENGTH is 2^32 bytes (~4GB) — very much reachable, not a theoretical ceiling. Older Node versions (pre-V8's larger typed-array support) capped a single Buffer at 2^31 - 1 bytes — just under 2GB — which mattered a great deal for this module's use case. A settlement file at the upper end of the "2–5GB per hour" range described below would have exceeded that older ceiling outright, and can still get uncomfortably close to today's ~4GB ceiling; Buffer.alloc(fileSize) for a file that size isn't just wasteful, it risks ERR_OUT_OF_RANGE on any runtime.

The practical takeaway hasn't changed even though the number did: MAX_LENGTH is a safety net for catching a corrupted or unexpectedly large size value passed into an allocation call, not a budget you should ever approach. If a call to Buffer.alloc() or Buffer.allocUnsafe() in your ingestion path is anywhere near this ceiling, that's a signal the architecture has reverted to loading an entire file into one buffer instead of streaming it — exactly what Transform and pipeline() below exist to avoid.

Buffer vs TypedArray vs ArrayBuffer

javascript

For ingestion pipelines: use Buffer. It has the convenience methods (read/write integers at offsets, copy, compare) needed for binary protocol parsing. TypedArray is better for numeric computation where V8's typed array optimization applies.


Off-Heap, Not Zero-Copy

It's worth being precise about what this module actually delivers, because "off-heap" and "zero-copy" are not the same claim. Every example in this module moves data off the V8 heap — but none of them is zero-copy. The TransactionDecoder below calls chunk.copy(this.#scratch, this.#writeOffset) on every incoming chunk; the buffer pool's processBlock calls chunk.copy(buf, offset). Both are real, userspace memcpy operations. The data is off-heap the entire time, but it is copied — at least once, usually from the kernel's socket or file buffer into a buffer your code controls.

What true zero-copy would require:

  • Operating on offsets into the original chunk, with no re-buffering. If a complete message is guaranteed to arrive within a single chunk (no fragmentation across the chunk boundary), you can call decodeTransaction(chunk.subarray(start, end)) directly — subarray returns a view, not a copy — and skip copying into a scratch buffer entirely. This is why fragmentation is the deciding factor: the moment a transaction can be split across two chunks (which fragmented TCP delivery guarantees will happen eventually), its bytes need to live somewhere contiguous once both chunks have arrived, and that somewhere doesn't exist until you copy them there. The scratch-buffer pattern used throughout this module is the correct response to that constraint — it isn't zero-copy, but it is off-heap and O(1) per byte.
  • sendfile(2)-style kernel-to-kernel transfer, for the narrower case of moving bytes from a file descriptor straight to a socket descriptor with no transformation in between. The kernel copies the bytes directly, without ever mapping them into your process's userspace. Node itself doesn't use this for fs.createReadStream().pipe(res) — that path still reads into userspace buffers via fs.read() and writes them out with a normal write() syscall, not a true zero-copy kernel transfer (a long-standing open feature request against Node core confirms this isn't implemented today). Reverse proxies like Nginx serving static files do use real sendfile(2). Either way, it stops applying the instant you need to decode, validate, reconcile, or transform the bytes, because the kernel has no way to run your JavaScript mid-transfer — which rules it out for every ingestion pipeline this module builds.

So: the correct claim for this module is off-heap, not zero-copy. The data never touches V8's GC-managed heap and never triggers GC pressure regardless of file size — that's the guarantee this module delivers. But your code still performs real copies of the bytes; what the scratch-buffer and buffer-pool patterns eliminate is redundant copying (the O(N²) blowup from repeated Buffer.concat), not copying itself.


Transform Streams: The Core of Every Ingestion Pipeline

A Transform stream is both a Readable and a Writable. It receives data chunks in, transforms them, and emits transformed chunks out. It is the correct abstraction for:

  • Decoding raw binary transaction data into JavaScript objects
  • Parsing line-delimited JSON from a log file
  • Decompressing compressed data streams
  • Normalizing inconsistent data formats

Sign in to keep reading

The rest of this module is free — sign in with Google to unlock it and track your progress.

Sign in & Register

Discussion

0

Join the discussion

Loading comments...

© 2026 Jatin Jain Saraf (JJS). All rights reserved.