Byte custody  /  Node.js

A map of who is holding the data, at every layer, at every instant

Where are my bytes right now?

Every layer of a Node program has something it calls a buffer, and they are not the same thing — some of them store your bytes, some only point at them, and one of them holds nothing at all while appearing to do the most work.

This page answers one question in one way: pick a moment in the journey, and it tells you which layer physically holds the data, who owns that memory, and whether what you are looking at is a copy, a window, or an address.

00 The short answer

Three rules that decide every case

The confusion is not really about buffers. It is about ownership. Once you know which side of an ownership boundary a piece of memory sits on, everything else — whether it copies, whether two things can see the same bytes, whether freeing one frees the other — follows from that.

The whole answer in one shape
your process JavaScript objects, stream queues, the block the read landed in one address space SYSCALL BOUNDARY — the only place a copy happens the kernel per-socket receive and send queues, the page cache another address space DMA BOUNDARY — the device writes straight into memory the driver mapped the device rings the driver owns
Everything people call “a buffer” sits in one of these three bands. Two things live in the top band and get confused with each other constantly: the block the operating system wrote into, and the queue a stream keeps. They are both in your process, and one holds windows onto the other.
The count, for the path you have selected: bytes come to rest in 4 places on the way in and 5 on the way out, and exactly 2 of those transitions are copies.

01 Three territories, and what each one calls a buffer

Same word, four owners

Colour is doing the work on this page: every box, chip and bar is tinted by who owns the memory, and the tint gets warmer the closer the memory is to your code. If two things share a colour they can see each other's memory directly. If they don't, something has to copy.

device · driver kernel Node's native side JavaScript
Called a bufferWhat it actually isOwnerSized by
NIC ring / device queueFixed slots the device writes into by DMAdriverthe driver, at boot
Socket receive queueReassembled, in-order stream bytes waiting for you to read themkernelautotuned, capped by the socket option
Socket send queueBytes already handed over but not yet acknowledged by the peerkernelautotuned, capped by the socket option
Page cacheFile contents the kernel is holding on behalf of every processkernelfree memory
The read blockA fixed-size chunk of process memory Node hands to the OS to fillNode nativea constant, roughly 64 KB
The write queueA list of addresses and lengths waiting for the socket to become writablelibuvhowever much you wrote
A readable stream's queueAn array of chunk objects that have arrived but not been consumedJavaScriptwhatever arrived before you read
A writable stream's queueAn array of chunk objects accepted but not yet handed downJavaScriptwhatever you wrote before it drained
A chunk in your callbackA window: an offset and a length into one of the blocks aboveJavaScriptwhat the parser or the read produced
Two rows above are the ones that get merged in most people's heads. The read block is memory the operating system filled; a stream's queue is a JavaScript array of chunks. The queue does not contain bytes — it contains objects that point into blocks. One block can be pointed at by several queued chunks at once, and it stays alive as long as any of them does.

02 Follow the bytes

Step through one journey and watch custody change hands

This is the instrument the rest of the page explains. Step through a single journey and at every stop it tells you three things: who holds it, what kind of holding that is, and what you could type to see it. The ledger underneath fills as you go, so by the end you have the copy count for that path rather than a feeling about it.

Custody — HTTP request and response

Who holds it now

—

Press forward to start the journey.

Holding
—
Freed when
—
Visible as
—
StopOwnerHolding
empty
copies 0windows 0addresses 0
Every stop is a place bytes can physically be found. A stop marked copy means new memory was written; window means no new memory at all, only a different offset and length onto memory that already existed.

03 Copy, window, address

The three kinds of holding, and why only one of them costs memory

“Where are my bytes” is ambiguous because there are three quite different ways a layer can be holding something, and only one of them means the bytes are actually there.

KindWhat existsCostsWhere it happens
copyA second set of the same bytes in different memoryThe full length, twice over, until one is releasedOnly at an ownership boundary, or when you ask for it
windowAn object recording an offset and a length into memory that already existsA few dozen bytes for the object, whatever the lengthEverywhere inside your process
addressA note of where the bytes are, held by a layer that will read them laterNothing — but the target must stay alive until it is doneThe native write queue

The consequence people get bitten by

Because a chunk in your callback is a window and not a copy, the block underneath it stays alive for exactly as long as you keep that chunk. A small window can pin a large block.

A small window pins a whole block

The block the operating system filled — 65,536 bytes

your chunk: 640 bytes
640 B
what you think you kept
64.0 KB
what is actually still allocated
Keeping the window keeps the block. Retain a thousand small chunks from a thousand connections and you are holding a thousand whole blocks, not a thousand small strings.
Copying the chunk out — turning it into a string, or into memory of its own — is what releases the block. That copy is one you asked for, and it is often the right call.

04 Inbound moves. Outbound duplicates.

Why the answer to “where is it” is sometimes one place and sometimes two

The two syscall crossings look symmetrical and are not. This is the single fact that makes the question answerable, because it tells you when the bytes are in one place and when they are in several at once.

The same bytes, counted at one instant
just after the read call returns kernel receive queue released — the window reopens your block holds the only copy copies in existence 1 one owner, one set of bytes
The dashed frame means the bytes are gone from there; the solid frame means they are present. Switching to writing turns both frames solid, which is the whole point.

05 Three kinds of full

One word, backpressure, naming three mechanisms with completely different teeth

Every layer that queues has a notion of being too full, and they are not the same notion. Knowing which one you are looking at tells you whether something will stop you, slow you, or quietly let you run out of memory.

Stall one end and watch it back up
Press run. Each bar fills only once the one below it has no more room, so the order in which they fill is the order in which the pressure travels.
The red mark on a bar is a threshold that reports but does not stop. A bar with no mark has a hard ceiling: when it is full, the layer below it refuses, and that refusal is what pushes the pressure upward.
This is why the two ends behave so differently when your code stalls. Stop reading and the pressure reaches an enforced limit within one round trip — the sender is physically stopped and your memory stays flat. Ignore a write returning false and the pressure has nowhere to go but your own heap, because everything above the kernel accepts whatever it is given.

06 What the request and response objects really are

Neither one is what its base class suggests

The request object is a readable stream that nothing ever pulls from. A normal readable asks its source for more when it runs low. This one doesn't: the HTTP parser pushes into it as bytes are parsed, and its queue is a place things are put, not a place things are fetched from. Its only real control is being able to say “stop asking the socket for more” when it is over its threshold.

The more surprising half is where the parser sits. On an HTTP connection the parser is attached below the socket's own stream machinery, so incoming bytes go from the read straight into parsing. The socket's readable queue is not in the path at all — it exists, it is empty, and reading from it is not how the request body reaches you.

The same socket, two different paths through it
raw TCP socket HTTP connection the read fills a block the socket's readable queue your data handler one queue, and you read from it the read fills a block the parser reads it in place the request object's queue the socket's own readable queue present, empty, never in the path
Both columns start with the same block of memory. What differs is which layer is allowed to look at it first, and therefore which queue ends up holding windows onto it.

The response object is two queues, not one

The response is not a plain writable stream either. It sits above the socket and keeps a pending list of its own, because it has work to do that the socket knows nothing about: turning the status line and headers into bytes, deciding chunked framing, and grouping small writes so they leave as one segment rather than several.

07 What libuv actually keeps

The layer that appears to move all the data and stores almost none of it

This is the layer worth being precise about, because “libuv buffers the data” is the assumption that makes everything else hard to reason about. Split it by direction and it becomes simple.

ReadingWriting
Who supplies the memoryNode does, on demand, when the socket becomes readableYou did — it is the memory your chunk already points at
What libuv holdsNothing after the callback returnsA queue of addresses and lengths
Does it copyNo — it passes your memory to the OS to fillNo — it passes your addresses to the OS to read from
What must stay aliveThe block, for as long as anything windows onto itYour chunk, until the write completes
How full it can getNot a queue at all — one block per readable eventUnbounded; it is the pending-write backlog
A pending write, drawn honestly
memory JavaScript still owns chunk A chunk B chunk C the pending write queue three entries, each an address and a length arrows are addresses, not transfers kernel send queue took what fit, refused the rest — which is why the queue below exists
Nothing was copied downward here. The bytes are still in one place, in your process, referred to from below. Release those chunks before the write completes and you would be handing the operating system the address of memory that is gone — which is precisely why the runtime holds a reference for you until the callback fires.

08 How to see each layer

Turning the model into something you can check at three in the morning

Every layer that holds anything can be observed, and the numbers disagree with each other in useful ways. If your process memory is climbing, the layer whose number is climbing with it is the one holding your data.

LayerWhat to look atWhat a large number there means
JS readable queueThe stream's readable lengthData arrived faster than your handler consumed it
JS writable queueThe stream's writable lengthYou are writing faster than the layer below accepts — the classic ignored return value
JS response pending listThe response's output sizeHeaders or body written before the socket could take them
native write queueThe handle's write queue sizeThe kernel refused; the socket is not writable yet
kernel receive queueSend queue and receive queue columns in a socket listingBytes have arrived that nothing in your process has read
kernel send queueThe same listing, sending sideBytes handed over and not yet acknowledged — a slow or distant peer
native blocksTotal process memory minus the JS heapBlocks are being pinned by windows you are still holding
JS your own retentionA heap snapshot, looking at what keeps chunks reachableYou are accumulating chunks instead of streaming them
The pair worth internalising: a JS queue growing while the native and kernel numbers stay near zero means the bottleneck is you, not the network. The native and kernel numbers pinned high means the peer or the link is the bottleneck and your queue is the symptom. They call for opposite fixes.

09 Mark the claims

Some of these are true

Answer before revealing. The ones you get wrong are the layers your model is still merging.

Back to JIT-Atlas