Node internals · the dispatch chain
Who calls
your callback
It is not libuv. libuv gets the native event to Node; Node is what turns a native event into JavaScript behaviour. This document is the two layers that sentence usually leaves out.
You write socket.on('data', h) and later
h runs.You write req.on('data', h) and later
h runs.
Between those two events are six handoffs, two language boundaries and
exactly one copy of your bytes.
libuv does not know your
function exists. It does not know what
'data' is, what an
EventEmitter is, or what a
Buffer is. Those are Node's ideas, and Node is the
translator.
Two directions, and only one of them is yours
Setting an operation up and having it complete are two different journeys through the same six layers, in opposite directions, using different machinery.
Going down is a chain of ordinary function calls: a method on a JavaScript object reaches a C++ object, which reaches a libuv handle, which makes a system call. It happens on your stack, it happens immediately, and it returns immediately.
Coming back up is nothing like that. It begins with a hardware interrupt in a process that is not scheduled, and it arrives in JavaScript only because Node left itself a trail of pointers on the way down.
This document is written twice. net shows the shorter chain, where the socket is the stream you read. http shows the same chain with a parser and a second Readable stacked on it. Flipping the switch rewrites every figure, every code sample and the sentences around them.
The reason it is worth a switch rather than a paragraph: the bottom four layers do not change. Figures 04 through 08 are byte-identical in both modes, and that is the layering claim made checkable rather than asserted.
Registration is an array push, and nothing else
When you call socket.on('data', h), theWhen you call req.on('data', h), the
first thing that happens is the least interesting thing that could
happen: your function is stored in a table on the object, under the
string you passed.
The useful mental model in most explanations is
_events = { data: handler }, and it is close enough to be
worth having. The real shape is slightly different in a way that
matters when you go looking: Node stores the function
itself for a single listener, and only promotes the slot to an
array on the second one.
Nothing native has been touched. No descriptor, no system call, no libuv handle. Registering interest is cheap precisely because it does nothing — which raises the obvious question of what does.
Who actually tells the socket to read
This is the section the switch at the top of the page exists for, because net and http give different answers, and the difference explains a great deal of otherwise confusing behaviour.
In net, your listener is the trigger.
Readable.prototype.on does one extra thing beyond storing
the function: if the event is 'data'
and the stream has not been explicitly paused, it resumes it. That
resume is deferred a tick, then reaches
flowing mode, then _read,
then the C++ handle, then
uv_read_start.
In http, the answer is that
it already happened. The connection socket has been
reading since the moment it was accepted — it has to be, because the
server cannot know a request exists until it has read and parsed the
request line. By the time your handler runs,
llhttp has already consumed the headers
off the wire. Your req.on('data')
resumes a second Readable
(http.IncomingMessage) whose source is
the parser, not a descriptor.
uv_read_start is handed
two native callbacks, not one:
alloc_cb and
read_cb. Almost every explanation of
this chain mentions only the second, which leaves no answer to the
question of where the data Buffer's memory came from.
§05 is that answer.
Interest, not polling
libuv is not sitting in a loop asking the socket whether anything has arrived. It registers interest once and then blocks, and a socket that becomes readable appends itself to that epoll instance’s ready list. The distinction is the whole reason ten thousand idle connections can cost nothing.
Five bytes arrive, and Node does not run
A peer sends hello. What follows is entirely the kernel's
and the hardware's, and it is worth stepping through precisely because
no Node code of any kind executes during it.
The acknowledgement goes back out before your process learns anything. TCP is not implemented by Node; the sequence numbers, the window, the retransmission and the reassembly all happen in a layer that has never heard of JavaScript.
Where your Buffer actually comes from
Before every read, libuv calls back into Node to ask for somewhere to put bytes that do not exist yet. This is the step most descriptions of the chain omit, and it is the answer to a question they therefore cannot answer.
libuv cannot allocate the memory itself. It has no idea what a
Buffer is, and it must not decide how large your process's
reads are. So alloc_cb receives an
out-parameter and Node fills in a pointer and a length —
memory travelling the other way across the boundary.
Because Node answers out of a
pool, the chunk your
handler receives is usually a view into a larger allocation
shared with other chunks. Keeping a five-byte chunk can retain the
whole thing. Buffer.from(chunk) exists for exactly this,
and chunk.byteOffset is rarely zero.
One native callback, three different meanings
The other callback handed to
uv_read_start is
read_cb, and its second parameter is a
signed integer. That sign is doing a great deal of work:
data, end of stream and failure all arrive through the same C
function.
Which is why the three become three different JavaScript events in
Node's C++ layer rather than in libuv.
'data', 'end' and
'error' are Node's vocabulary; libuv reported one number.
How a native event finds a JavaScript object
The event arrives naming a descriptor. Your handler expects an object. Between the two are three stored pointers and one piece of pointer arithmetic (container_of) — and, importantly, no search of any kind.
The pivot is handle->data, a free-form
void* that libuv reserves on every handle and never
touches. Node stores its C++ wrapper there. That one pointer is
the seam between a general-purpose C library and a specific
runtime — follow it, and you have left libuv.
Crossing into V8 is more than a function call
Node does not simply call a JavaScript function from C++. MakeCallback re-enters
JavaScript with bookkeeping that a plain call would skip, and one piece of it explains an ordering rule you have
probably relied on without knowing where it lives.
When the outermost native-to-JavaScript call returns, Node drains the
entire nextTick queue and then the microtask queue
before control goes back to libuv. So a promise you resolve inside a
'data' handler settles before the next
chunk can be delivered — not because promises are special, but
because the drain happens
on the way out of the callback that delivered this one.
Note also what V8 is asked to call. It is not your function. It is
onStreamRead, Node's own JavaScript, with three or four
more frames still to go.
The last few frames, where the two APIs finally differ
In net it is short: Node's JavaScript
pushes the chunk into the socket's own Readable, the stream is flowing
so the queue is bypassed, the socket emits, and the emitter calls you.
In http it is longer, and the socket's
only 'data' listener is not yours — it
belongs to the server. Bytes go to
llhttp first, which fires its own
callbacks synchronously, and the body slices are pushed into
req: a second Readable, with its own
flowing state and its own backpressure.
In both cases your function is the last frame on a stack that still has the native read callback at the bottom of it. You are running inside the C++ call that started this. Block here and nothing else in the process runs, because there is nothing else running.
Two things called “the callback”
A great deal of confusion about this chain comes from one word doing two jobs. There are two callbacks involved and they share nothing: not a language, not a caller, not an argument.
Which is why “libuv calls my callback” is wrong in a specific and fixable way. libuv gets the native event to Node. Node turns that native event into JavaScript behaviour. The middle is not a detail; it is the part that does the work.
The responsibility map
Every layer in this document is easiest to hold by what it cannot know. Responsibilities are memorable; the gaps between them are what make the architecture make sense.
The kernel produces an OS-level event; libuv observes it and
dispatches a native callback; Node's C++ layer translates
that into a Node operation; V8 executes JavaScript; Node's stream and
EventEmitter machinery reaches your
function.
Six handoffs, two boundary crossings, one copy — and only the top two rows of that list changed when you flipped the switch.