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.

12 sections12 figuresone switch, whole pagev1.0

this whole page reads every figure, code sample and sentence below switches together. The same control stays in the bar at the top of the window as you read

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.

§00

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.

FIG 01 Down is a call. Up is a delivery.
the switch at the top of this page

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.

§01

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.

FIG 02 What the listener table actually holds

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.

§02

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.

FIG 03 From a listener to a libuv call
the detail worth carrying away

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.

§03

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.

FIG 04 One syscall, and then a thread that is not running
below here, the switch does nothing This figure and the next three are identical whichever API is selected. Flip the switch and watch: the tabs, the diagrams and the numbers do not move. That is the layering claim, made checkable.
§04

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.

FIG 05 From the wire to a ready list

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.

§05

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.

FIG 06 The allocation callback, the pool, and the one copy
a real leak shape

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.

§06

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.

FIG 07 nread, and what its sign decides

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.

§07

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.

FIG 08 fd 42 → uv_tcp_t → TCPWrap → the JavaScript object

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.

§08

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.

FIG 09 MakeCallback, and the drain on the way out
why await resumes before the next chunk

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.

§09

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.

FIG 10 Node's JavaScript, then the emitter, then you

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.

§10

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.

FIG 11 Native callback ≠ JavaScript callback

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.

§11

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.

FIG 12 What each layer does, and what it has never heard of
the chain, in one sentence

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.

Back to JIT-Atlas