A Buffer is a view onto memory outside the V8 heap
Every read in this series has handed you one. fs.readFile in Part 5, socket.on("data") in Part 8, the request body in Part 9 — all Buffers, and the reason is the same in every case.
A Buffer is a Uint8Array whose bytes live in a C++ allocation rather than in the JavaScript heap. It is a normal typed array in every other respect: indexable, iterable, with a length. What is unusual is where the bytes are, and that one difference explains the rest of this part.
const buf = Buffer.from("hi");
buf instanceof Uint8Array; // true
buf[0]; // 104
buf.length; // 2
// and the bytes are NOT in the V8 heap:
buf.buffer; // an ArrayBuffer allocated by C++The reason is Part 5's and Part 8's mechanism. A pool thread or the kernel is going to write into this memory while your JavaScript is not running, and V8's garbage collector moves objects around inside its heap. An address the collector may relocate is useless to a syscall.
Buffers exist because the kernel needs a stable address and V8 cannot promise one. Everything else — pooling, encodings, allocUnsafe — is a consequence of living outside the heap.
A view, a window, and the memory itself
A Buffer looks like one object and is really a small stack of three, which is why two Buffers can share bytes without either of them knowing.
subarray() makes a new view with a different window over the SAME memory — no copying, and no isolation either.const buf = Buffer.from("hello world");
const view = buf.subarray(0, 5); // no copy
view[0] = 72; // 'H'
console.log(buf.toString()); // "Hello world" <- buf changed too
console.log(view.buffer === buf.buffer); // true — same memoryThat is the first thing to internalise, because it is the source of the most confusing bug in this part: a Buffer you were handed may be a window onto memory somebody else is still using.
Three ways to get bytes, with different costs and hazards
Node gives you several constructors and they are not interchangeable. The differences are about zero-filling and about whether your bytes are carved out of a shared block.
The pool is the part worth understanding, because it is invisible until it is not. Node keeps an 8KB slab and carves small allocUnsafe requests out of it; a Buffer under 4KB is usually a window onto that slab, sharing an ArrayBuffer with unrelated Buffers allocated near it in time.
allocUnsafe is not unsafe because it might crash. It is unsafe because the memory has a PREVIOUS OCCUPANT, and if you do not overwrite every byte you keep whatever was there.
Sharing an ArrayBuffer with strangers
Because pooled Buffers are views onto a shared slab, the underlying ArrayBuffer is almost never the size of your Buffer — and passing it around exposes more than you meant.
const a = Buffer.allocUnsafe(100); a.length; // 100 <- your view a.buffer.byteLength; // 8192 <- the whole pool slab a.byteOffset; // 168 <- where your window starts // so this hands somebody EIGHT KILOBYTES, most of it other people's data: someApi(a.buffer); // this hands them your hundred bytes: someApi(a.buffer.slice(a.byteOffset, a.byteOffset + a.length));
.buffer.This is the same distinction as the transfer list in Part 10, arriving from the other side. There, transferring an ArrayBuffer moved the whole allocation; here, reaching for .buffer on a pooled Buffer reaches past your window into everything sharing the slab.
buf.buffer is the SLAB, not your bytes. Use byteOffset and length, or copy — and prefer Buffer.alloc when the buffer is going to be handed to code you do not control.
toString does not convert anything
A Buffer holds bytes and nothing else. An encoding name does not transform them — it tells toString how to interpret them, and the same bytes read two ways give two different strings without anything in memory changing.
The lossy case is the one to watch. utf8 is not a passive interpretation: bytes that are not valid UTF-8 are replaced with U+FFFD, and the replacement is not reversible. Reading arbitrary binary as a string and writing it back out is how binary data gets quietly corrupted.
An encoding is a lens, not a conversion — except utf8, which will substitute characters for bytes it cannot read and tell you nothing.
Chunk boundaries do not respect characters
Parts 8 and 9 established that a chunk boundary lands wherever the network put it. Combine that with UTF-8, where a character can be up to four bytes, and a character can arrive split across two data events.
This is why StringDecoder exists, and why it is not optional anywhere text arrives in chunks. It holds back the incomplete bytes at the end of a chunk and prepends them to the next one — the same idea as the HTTP parser holding partial headers in Part 9.
Concatenating Buffers and decoding ONCE is always correct. Decoding each chunk separately is correct only while your test data is ASCII.
subarray shares, concat copies, and one loop is quadratic
Buffer operations divide cleanly into those that make a new window and those that move bytes, and the division is worth memorising because one of them turns a loop into an O(n²) loop.
The last row is the one that shows up in production, because it is invisible at small sizes. The loop is correct, the output is correct, and the cost is quadratic in the number of chunks — which is a property of the network, not of your data.
Ten claims about Buffers
Most of these come from treating a Buffer as a string that happens to hold bytes, rather than as a window onto memory.
A Buffer is a view. Ask "whose memory is this, and who else can see it" and the surprises in this part stop being surprising.
If you remember nothing else
The bytes are outside the heap, and you are holding a window onto them.
buf.buffer is the slab rather than your bytes.utf8 is the one that loses data silently — including when a character is split across two chunks.concat in a loop is quadratic in the number of chunks. Collect into an array and concat once.Part 7 is about the thing that hands you these chunks in the first place: a stream is an EventEmitter from Part 2 with a queue of Buffers and a rule about when to stop filling it.