Mission 03 · Node.js Internals Campaign

Buffer — a window onto memory outside the V8 heap

Every read in this series has handed you one. A Buffer is a Uint8Array whose bytes live in a C++ allocation rather than in the JavaScript heap — because the kernel needs an address that does not move and a garbage collector cannot promise one. Pooling, encodings and allocUnsafe are all consequences of living outside the heap.

9 sections13 figuresone windowalloc · pool · encodings · concat
§ 0 · THE SHORT ANSWER FIRST

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.

JAVASCRIPTIT REALLY IS A TYPED ARRAY
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 object and the bytes live apart
§0 · which side of the fence
the V8 heap · collected, and MOVED
the Buffer objecta few dozen bytes of headeran offset and a length, nothing more
→points at
outside the heap · never moved
the bytesa C++ allocation at a fixed addresswhat a syscall is handed a pointer to
A collector that relocates objects cannot hand a stable address to a pool thread that will write into it while your JavaScript is not running. That is the entire reason for the fence.
the object is collected and may move · the bytes are neither

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.

!Lock this in

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.

§ 1 · WHAT YOU ARE ACTUALLY HOLDING

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.

One allocation, two views, no copying
§1 · move the window
slide the offset, then write through the view and watch the original change
What a Buffer is made of
§1 · the same thing as a list
the Buffer
a Uint8Array subclass, in the V8 heap
main
The JS object itself IS in the heap and is tiny — a few dozen bytes of header. What it holds is not the data but a reference to it, plus an offset and a length.
byteOffset and length
the window this view can see
node
Where this view starts inside the underlying memory, and how far it extends. subarray() makes a new view with a different window over the SAME memory — no copying, and no isolation either.
the ArrayBuffer
the bytes, allocated by C++
kernel
Outside the V8 heap, at an address that does not move. This is what a pool thread writes into, and what a syscall is handed a pointer to.
the object is in the heap; the bytes are not
JAVASCRIPTTWO VIEWS, ONE ALLOCATION
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 memory

That 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.

§ 2 · ALLOC, ALLOCUNSAFE, AND THE POOL

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.

Pick an allocation and see what you got
§2 · where the bytes come from
the control is allocUnsafe — the one that can hand you somebody else's data

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.

!Lock this in

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.

§ 3 · WHY BUF.BUFFER IS RARELY WHAT YOU EXPECT

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.

JAVASCRIPTTHE SIZE MISMATCH THAT SURPRISES PEOPLE
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));
What buf.buffer measures, against what you meant
§3 · to scale, and magnified
the slab · one ArrayBuffer · 8192 bytesbuf.buffer.byteLength = 8192your 100 bytesbyteOffset 168earlier allocationsbuf.length = 100magnified 80xhand over .buffer and the whole bar goes with it — not the highlighted sliver
Drawn to scale, with the window magnified because it is 1.2% of the bar. The dimension line measures what buf.buffer actually is; the small block is what you meant to pass. Eighty-one times more memory, most of it belonging to allocations you never saw.
the dimension line is buf.buffer · the small block is buf
Your window, and everything around it
§3 · what buf.buffer actually hands over
buf — your 100 bytesearlier allocations, byteOffset 168whatever is allocated next
buf.length100what you think you are passing
buf.buffer.byteLength8192what you are ACTUALLY passing
the difference81× more memory than you meantmost of it other people's data
The green sliver is your Buffer. Everything else in that bar goes with it the moment you reach for .buffer.
the slab is the unit of allocation — your Buffer is a window onto it

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.

!Lock this in

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.

§ 4 · THE BYTES NEVER CHANGE

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 same bytes, interpreted differently
§4 · one buffer, five readings
the control is the reading that loses information — and does it silently

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.

!Lock this in

An encoding is a lens, not a conversion — except utf8, which will substitute characters for bytes it cannot read and tell you nothing.

§ 5 · THE BUG THAT ONLY APPEARS WITH MULTI-BYTE TEXT

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.

What each strategy produces
§5 · a character across a boundary
the control is per-chunk toString — correct on ASCII, and wrong the moment it is not

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.

!Lock this in

Concatenating Buffers and decoding ONCE is always correct. Decoding each chunk separately is correct only while your test data is ASCII.

§ 6 · WHICH OPERATIONS MOVE BYTES

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.

Per operation
§6 · what copies
subarray / slice
NO copy — a new view
node
Constant time regardless of size. It also means the new Buffer keeps the whole underlying allocation alive, which is how a 10-byte slice of a 10MB read can hold 10MB resident.
Buffer.concat([a, b])
copies every byte, once
main
Allocates a new buffer of the combined length and copies both in. Called once at the end, this is the right thing.
acc = Buffer.concat([acc, chunk])
copies EVERYTHING, every chunk
worker
The quadratic trap. Ten thousand 1KB chunks accumulated this way copies about 50 GB to build a 10 MB buffer. Push into an array and concat ONCE.
buf.toString()
copies into the V8 heap
kernel
A string is a heap object. Decoding a 100MB buffer allocates a ~100MB string beside it, and both are live until one is dropped.
views are free · copies are not · accumulating is worse than both
Where the bytes actually go
§6 · the same three chunks, three ways
subarray — no copy
view Aoffset 0, len 4
view Boffset 4, len 4
concat once — one copy
a new bufferevery byte moved, exactly once
concat per chunk — n copies
copy 11 KB
copy 22 KB
copy 33 KB
views share one allocationone concat moves n bytesn concats move n² / 2 bytes
Read the third frame as a staircase: each step copies everything received so far. Ten thousand 1KB chunks is about 50 GB of copying to assemble 10 MB.
two of these are linear · the third is the one that reaches production

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.

§ 7 · THE MODELS WORTH CORRECTING

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.

Which of these are true?
§7 · mental models
sort before you check — a guess you have committed to is worth more than a correction you read
!Lock this in

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.

§ 8 · THE FOUR SENTENCES WORTH KEEPING

If you remember nothing else

The bytes are outside the heap, and you are holding a window onto them.

What to carry away
§8 · the four sentences
the bytes live outside the heapthe kernel needs a fixed address§0 · §2
allocUnsafe hands you a previous occupantand buf.buffer is the slab§2 · §3
an encoding is a readingand utf8 loses data silently§4 · §5
subarray shares, concat copiesand per-chunk concat is quadratic§6
Four sentences, one window. Ask "whose memory is this, and who else can see it" and the surprises in this part stop being surprising.
four sentences, one window
What to carry away
§8 · the same thing as a list
the bytes live outside the V8 heap
§0 · §2
kernel
Because the kernel needs an address that does not move and a garbage collector cannot promise one.
allocUnsafe hands you a previous occupant
§2
worker
And small allocations are windows onto a shared 8KB slab, so buf.buffer is the slab rather than your bytes.
an encoding is a reading, not a conversion
§4 · §5
node
And utf8 is the one that loses data silently — including when a character is split across two chunks.
subarray shares, concat copies
§6
main
Accumulating with concat in a loop is quadratic in the number of chunks. Collect into an array and concat once.
four sentences, one window

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.

Back to JIT-Atlas