00 Explorable explanation · Node binary internals, Vol. II
Why a Buffer's bytes live outside the garbage collector — and how that lets the kernel hold a pointer.
Node lives in two memory worlds at once. V8's heap is a moving world: the garbage collector relocates objects to defragment, so no JS object has a stable address. The kernel's world is the opposite: read(2) wants a fixed pointer it can write bytes into. A server runtime has to bridge them — and almost every binary API in Node is a consequence of that one tension. Press play to watch a GC compaction run while a syscall holds a pointer, and see why the bytes have to live off-heap.
Press play to run a garbage collection
Amber objects live on V8's managed heap. The teal block is the off-heap backing store. Watch which addresses change when the collector compacts.
01 The root problem · two worlds that don't trust each other
One tension generates the entire API surface.
JavaScript grew up in the browser. Its only sequence-of-data type was the UTF-16 string, and every object lives on V8's garbage-collected heap — a moving, compacting collector that relocates live objects to defragment. The address of a normal JS object is therefore not stable across a GC cycle.
The operating system is the opposite. read(2) wants a pointer and a length and writes bytes there. write(2) wants a stable pointer it can hand to the NIC or disk controller. Bytes are bytes — no encoding, no UTF-16.
A server runtime must live in both worlds simultaneously, and that creates three hard constraints. Every API in this volume answers some part of them.
02 The standard layer · ArrayBuffer · TypedArray · DataView
The wrapper is on the heap. The bytes are not.
These three are the ECMAScript primitives Node inherits, and they already encode the copy-vs-view distinction that recurs everywhere later. An ArrayBuffer is a fixed-length region of raw bytes — deliberately opaque, you cannot index it. The V8 detail that matters: the small wrapper object sits on the GC heap, but the backing store — the actual bytes — is allocated off-heap with a stable address. V8 tracks it as external memory for collection pressure, but never moves or scans the payload. That stable backing store is exactly what makes ArrayBuffer the foundation for talking to the kernel.
| Primitive | What it is | Endianness | Speed |
|---|---|---|---|
| ArrayBuffer | The memory. Opaque, fixed-length, off-heap backing store. | — | storage only |
| TypedArray | A typed lens — interprets bytes as Uint8/Int32/Float64… | native only | JIT-fast indexed load |
| DataView | A lens with explicit endianness at any byte offset | you state it | real method call |
typedArray[i] is a plain indexed load the JIT can inline, but it reads in host byte order — non-portable the instant you serialize it. view.getInt32(off, false) is portable and alignment-agnostic, but each access is a real call doing bounds + endian work. Fast-and-native versus explicit-and-portable: the same fork appears again in Buffer's readInt32BE vs readInt32LE.
// same 8 bytes, three different lenses — the bytes never move const ab = new ArrayBuffer(8); // off-heap region, opaque const u8 = new Uint8Array(ab); // lens: bytes, native-endian const f64= new Float64Array(ab); // lens: 1 double, native-endian const dv = new DataView(ab); // lens: any type, explicit-endian dv.getInt32(0, false); // big-endian (network order)
03 The accessor matrix · struct packing, made tangible
Fifty methods are really one generated grid.
Buffer's sprawl of numeric accessors looks intimidating until you see the pattern that generates it: {read | write} × {UInt | Int | Float | Double | BigInt} × width × {BE | LE}. Pick a method, pick an offset, and you are doing exactly what a C struct parser does — choose offset, width, signedness, byte order, then twiddle. Below is a live 8-byte buffer wrapped by a real DataView. Change the lens and watch which bytes are consumed and what value falls out. The bytes never change; only the reading does.
new Int32Array(buf.buffer)[0] uses the platform's byte order with no annotation — almost always little-endian. Read a big-endian wire format that way and you get a plausible, totally wrong integer with no error. State BE/LE explicitly, every time.
04 Buffer · a Uint8Array with a slab allocator bolted on
alloc, allocUnsafe, allocUnsafeSlow — one safety/speed curve.
When V8 shipped real typed arrays, Node rebased Buffer to be a subclass of Uint8Array — the single most clarifying fact about it. Everything from the standard layer applies; the extra methods are a struct-packing toolkit and a set of encodings. The three allocators are points on one curve. Click the buttons to allocate into a real 8 KiB pool and watch where the bytes land.
allocUnsafe skips the zero-fill, so a fresh buffer holds whatever was last in that slab — old request bodies, freed key material. Send it before overwriting every byte and you have disclosed heap memory. (2) The pin: a small allocUnsafe buffer is a view into the shared 8 KiB pool, so keeping one reference alive pins the entire pool. For long-lived tiny buffers, reach for allocUnsafeSlow.
The from() inconsistency — copy or share?
The copy-vs-view fork bites here too, and it is inconsistent by design. Tap each to see what actually happens to the memory.
05 fs · the thinnest possible wrapper over the syscall
You manage the buffers and offsets, exactly as in C.
fs.open() returns an integer file descriptor — the same fd from open(2). fs.read(fd, buffer, offset, length, position) maps almost 1:1 onto pread(2): you supply the Buffer, the kernel fills length bytes at position into your buffer at offset. There is essentially no abstraction tax — you are handing the kernel your stable off-heap pointer. The encoding, if any, is a post-processing step applied to bytes that were always read as binary.
| Node call | Maps to | What it does with your bytes |
|---|---|---|
| fs.open() | open(2) | returns an integer fd; flags are the O_* flags |
| fs.read() | pread(2) | kernel fills your Buffer at position |
| fs.write() | pwrite(2) | hands your stable pointer to the kernel |
| fs.readv / writev | readv/writev(2) | scatter-gather: many buffers, one syscall |
| fs.readFile() | open+read+close | whole file as one Buffer (string only if you pass an encoding) |
| fs.createReadStream() | chunked read loop | 64 KiB Buffer chunks with full backpressure |
pread/pwrite on a worker thread pool (default UV_THREADPOOL_SIZE=4) and signals completion back to the loop. Modern Linux is moving some of this onto io_uring, which collapses the distinction — but the threadpool model is still the one to reason about. The next section makes it visible.
06 The event loop · threadpool vs readiness
Two completely different paths for two kinds of I/O.
Sockets sit directly on the epoll/kqueue/IOCP readiness loop — the OS tells libuv when a fd is ready, and the work is genuinely async. Regular files can't be polled, so file reads, DNS lookups, and CPU-bound crypto/zlib are dispatched to the 4-thread worker pool, where they block on a real thread until done. Fire the operations below and watch the path each one takes. Queue five file reads and you will see the fifth wait for a free thread.
Sockets ride the readiness loop. Files and CPU work go to the worker pool. Dispatch something.
07 Streams · backpressure is flow control
When the sink is slow, the source must be told to stop.
Streams are the abstraction over "data arrives in chunks over time," and by default those chunks are Buffers. The first-principles concept is backpressure. writable.write(chunk) returns false when the internal queue exceeds highWaterMark; the 'drain' event says resume. It is flow control in the same spirit as TCP's receive window — stopping a fast producer from exhausting memory against a slow sink. Drag the consumer speed and watch write() flip to false.
cork()/uncork() batch several writes into one underlying syscall — a deliberate Nagle. stream.pipeline(...) wires source to sink and propagates errors and cleans up every stream, which raw .pipe() does not. Transform streams (zlib gzip, crypto ciphers) read this same backpressure on both sides.
08 net · dgram · http · the framing problem
TCP has no message boundaries. UDP keeps them. That changes everything.
node:net is TCP riding uv_tcp_t on the readiness loop, and its crucial property is that TCP is a byte stream with no message boundaries. A 'data' event delivers whatever bytes happened to be available — maybe half a logical message, maybe three glued together. The Buffer chunks have no relationship to your protocol's framing. Toggle the two sends below to see them arrive split, then merged.
| Module | Boundaries? | The gotcha |
|---|---|---|
| net (TCP) | none | you frame messages yourself: length-prefix or delimiter |
| dgram (UDP) | preserved | one 'message' = one datagram = one Buffer; loss & reorder instead |
| http / https | built on net | body as Buffer chunks; headers parsed by C state machine llhttp |
socket.setNoDelay(true) disables Nagle's algorithm, which otherwise coalesces small writes to reduce packet overhead. Off = lower latency per write; on = fewer, fuller packets. cork/uncork is the userland cousin of the same idea.
09 string_decoder · a correctness fix for chunking
A small stateful machine that survives the seam.
UTF-8 code points are 1–4 bytes. When a binary stream is split at arbitrary points — TCP packet boundaries, fs read sizes — a chunk boundary can fall inside a multi-byte sequence. Decode each chunk independently with chunk.toString('utf8') and both seams emit the replacement character �, corrupting the character. (Volume I traces this byte-by-byte for "café €99".) The fix is one function with memory: decoder.write() holds back any incomplete trailing sequence and carries it to the next call; decoder.end() flushes.
const { StringDecoder } = require('node:string_decoder'); const d = new StringDecoder('utf8'); d.write(chunk1); // → "café " (retains the dangling E2 internally) d.write(chunk2); // → "€99" (prepends E2, completes the codepoint) d.end(); // → "" (nothing buffered — output is byte-perfect) // the payoff you rarely write by hand: readable.setEncoding('utf8'); // installs a StringDecoder on the stream
toString() is stateless per chunk. The fix has to live somewhere that remembers a partial sequence between calls. setEncoding() simply wires this decoder into the stream's internal read path, so every 'data' event already hands you whole-character strings.
10 crypto & zlib · pointers straight into C libraries
Byte-oriented because the underlying work is byte-oriented.
Both modules hand your Buffer's stable pointer almost directly to a compiled C library — OpenSSL for crypto, zlib/brotli for compression. They operate on Buffers because letting UTF-16 or an ambiguous encoding touch key material or hash input is how you get subtle, dangerous bugs: the same input must always mean the same bytes. Each card opens the gotcha that bites people.
zlib variants run on the same libuv thread pool as fs — not the event loop. So do crypto.pbkdf2 and scrypt. This is why a flood of password hashes can starve your file I/O: they share four threads.
11 Node-API · the zero-copy bridge to native memory
The bytes never move. JavaScript just gets a lens.
This is the bottom of the stack, and it makes the whole "where do the bytes live" theme explicit. The interesting calls are the external variants: napi_create_external_buffer wraps memory you already have — a pointer from mmap, a shared-memory segment, a frame from a hardware device — and exposes it to JS as a Buffer without copying a byte. You provide a finalizer so that when the JS wrapper is garbage collected, your C code frees the underlying memory. That finalizer is how two ownership models — GC and manual free — are reconciled across the seam.
12 The one sentence that regenerates the list
Every API was one decision, made once.
The footguns, gathered
| Footgun | What goes wrong |
|---|---|
| allocUnsafe leak | uninitialised pool slab exposes old data — overwrite fully or use alloc |
| pool pinning | a tiny allocUnsafe view keeps the whole 8 KiB pool alive — use allocUnsafeSlow for long-lived |
| subarray aliasing | subarray/slice share memory; Uint8Array.prototype.slice copies |
| silent endianness | plain TypedArray uses host order — state BE/LE |
| per-chunk toString() | multi-byte char split across chunks → �; use StringDecoder / setEncoding |
| TCP framing | no message boundaries — reassemble messages yourself |
| threadpool starvation | fs + crypto + zlib share four threads by default |
| missing finalizer | external buffer with no finalizer leaks the native allocation |