§ 0 · The Short Answer FirstOne thread, sleeping in epoll_wait
This lesson takes the skeleton from Part 5 and changes one critical thing: the path through libuv is not the worker pool anymore. Sockets do not block worker threads. Sockets use the kernel's readiness machinery — epoll on Linux, kqueue on macOS/BSD, IOCP on Windows — and the main JS thread itself is the one that sleeps in epoll_wait.
We will trace this program:
const net = require("net"); const server = net.createServer((socket) => { socket.on("data", (chunk) => { socket.write("you sent: " + chunk); }); }); server.listen(3000);
…all the way from your JavaScript text down to the network interface card and back up to your 'connection' and 'data' event handlers.
When a Node TCP server is running, three things are true at once:
- The main JS thread is the only thread executing your JavaScript.
- That same main JS thread is also the thread sleeping in
epoll_wait(orkqueue/ IOCP) waiting for socket events. - No libuv worker thread is involved in plain TCP I/O — not for
accept, not forread, not forwrite.
fs.readFile uses libuv's worker pool because filesystem syscalls don't have a great non-blocking story. TCP sockets use libuv's event loop + readiness APIs because the kernel is happy to tell us "this socket has bytes ready" without anyone having to block.The whole point of this lesson is to internalize that distinction. Same Node, same libuv, same main JS thread — completely different machinery underneath.
§ 1 · The Full Ownership ChainSame shape as Part 5 — notice what changes
Memorize the layers, then notice what's different from Part 5. The middle of the stack swaps out: instead of the libuv worker pool, we have the OS readiness API and the kernel's TCP/IP stack.
Arrow flow (top to bottom)
JavaScript API
↓
Node JS wrapper / library layer (lib/net.js, EventEmitter)
↓
Node C++ binding (TCPWrap, ConnectionWrap)
↓
libuv (uv_tcp_t, uv_loop_t, uv__io_t)
↓
OS readiness API (epoll / kqueue / IOCP)
↓
kernel TCP/IP stack (sockets, accept queue, recv/send buffers)
↓
network driver + NIC + wire (Ethernet frames, IP packets, TCP segments)
↓
completion path back upward
↓
event emitted on main JS thread ('connection', 'data', 'end', 'close')Boxed view — every layer that matters
┌──────────────────────────────────────────────┐
│ Your JavaScript │
│ net.createServer(...).listen(3000) │
└──────────────┬───────────────────────────────┘
▼
┌──────────────────────────────────────────────┐
│ Node.js JS layer (lib/net.js) │
│ - Server (EventEmitter) │
│ - Socket (Duplex stream) │
│ - listen(), connection handling │
└──────────────┬───────────────────────────────┘
▼
┌──────────────────────────────────────────────┐
│ Node C++ bindings │
│ - TCPWrap (wraps uv_tcp_t) │
│ - ConnectionWrap (accept handler) │
│ - StreamWrap (read/write, onread cb) │
└──────────────┬───────────────────────────────┘
▼
┌──────────────────────────────────────────────┐
│ libuv │
│ - uv_tcp_t handles │
│ - uv_loop_t event loop │
│ - uv__io_t watchers │
│ - per-platform poll backend │
└──────────────┬───────────────────────────────┘
▼
┌──────────────────────────────────────────────┐
│ OS readiness API │
│ Linux: epoll │
│ macOS: kqueue │
│ Windows: IOCP │
└──────────────┬───────────────────────────────┘
▼
┌──────────────────────────────────────────────┐
│ Kernel TCP/IP stack │
│ - struct socket / struct sock │
│ - listen / accept queues │
│ - per-socket recv & send buffers │
│ - TCP state machine │
└──────────────┬───────────────────────────────┘
▼
┌──────────────────────────────────────────────┐
│ Network driver + NIC │
│ - ring buffers (RX / TX) │
│ - DMA to/from RAM │
│ - hardware interrupts → softirqs │
└──────────────┬───────────────────────────────┘
▼
┌──────────────────────────────────────────────┐
│ Wire / Internet │
└──────────────────────────────────────────────┘We will descend this stack, then climb back up.
§ 2 · CPU and Threads — the Network AngleThe CPU never understood "the internet"
You already know from Part 5: a CPU executes machine instructions, a process is a resource container, a thread is an execution path. Nothing about networking changes that.
┌──────────┐
│ FETCH │ ← read next instruction
└────┬─────┘
▼
┌──────────┐
│ DECODE │
└────┬─────┘
▼
┌──────────┐
│ EXECUTE │ ← may include `syscall` instruction
└────┬─────┘
▼
┌──────────┐
│ NEXT IP │
└──────────┘What the CPU still does not understand:
- TCP connections
- ports
- IP addresses
- HTTP
- "the internet"
These are abstractions built on top of the kernel's networking subsystem, which itself is just code running on the CPU like everything else.
§ 3 · What is a Socket, Really?An fd into a kernel object you never touch
A socket is a kernel object. From user space you never touch it — you get an integer file descriptor that points to it, the same way an open file descriptor points to a kernel file object.
Process fd table — files and sockets side by side
┌─────────────────────────────────────────────────┐ │ Node process — file descriptor table │ │ │ │ fd → kernel object │ │ ───────────────────── │ │ 0 → stdin │ │ 1 → stdout │ │ 2 → stderr │ │ 15 → open file: file.txt (from Part 5) │ │ 17 → TCP listening socket ◀── new! │ │ 18 → TCP accepted socket ◀── new! │ │ │ └─────────────────────────────────────────────────┘
Below the fd is the actual kernel socket structure. On Linux it looks roughly like this:
┌──────────────────────────────────────────────┐
│ struct socket │
│ - type (STREAM / DGRAM / RAW) │
│ - state (UNCONNECTED, LISTENING, ...) │
│ - ops (protocol operations table) │
│ - sk: pointer to ───────────────┐ │
└───────────────────────────────────┼──────────┘
▼
┌──────────────────────────────────────────────┐
│ struct sock (the protocol-level state) │
│ │
│ - local addr / port │
│ - peer addr / port │
│ - TCP state (LISTEN, ESTABLISHED, ...) │
│ - sk_receive_queue (incoming bytes) │
│ - sk_write_queue (outgoing bytes) │
│ - accept queue (for listening sock) │
│ - congestion / window state │
│ - timers (retransmit, keepalive) │
│ - wait queue (threads to wake on events) │
└──────────────────────────────────────────────┘17 is not the socket. It is a process-local index into a kernel-managed table. The kernel-side struct socket / struct sock holds all the actual state — connection state, buffers, addresses, timers. Your process never reads or writes these directly.§ 4 · TCP from First PrinciplesReliable, ordered, byte-stream — and the kernel does the handshake
TCP is a reliable, ordered, byte-stream protocol over IP. Three properties matter for our lesson:
- Connections are explicit. A TCP connection is a 4-tuple
(local IP, local port, peer IP, peer port)that both ends agree exists. - The 3-way handshake establishes the connection: SYN → SYN-ACK → ACK.
- Bytes are buffered in the kernel on both ends. The application reads and writes into kernel-side buffers; the kernel handles retransmits, ordering, and flow control.
The handshake, drawn
Client Server │ │ │── SYN (seq=X) ─▶│ │ │ put on SYN queue (incomplete) │◀─ SYN-ACK (seq=Y, ack=X+1) ──│ │ │ │── ACK (ack=Y+1) ─▶│ │ │ move to ACCEPT queue (complete) │ │ │ connection ESTABLISHED │ │ │
The crucial point: the kernel completes the entire handshake by itself, before your accept() call ever runs. By the time you "accept," the connection already exists in the kernel's accept queue.
┌────────────────────────────────────────────────────┐ │ Listening socket (fd 17) — kernel state │ │ │ │ ┌──────────────────────┐ ┌──────────────────┐ │ │ │ SYN queue │ │ Accept queue │ │ │ │ (half-open conns) │ │ (fully handshaked│ │ │ │ │ │ conns waiting │ │ │ │ - conn from C1 (SYN) │ │ for accept()) │ │ │ └──────────────────────┘ │ │ │ │ │ - conn from C2 │ │ │ │ - conn from C3 │ │ │ └──────────────────┘ │ └────────────────────────────────────────────────────┘
accept() doesn't create a connection. It pops an already-completed connection off the kernel's accept queue and gives your process a new fd for it.§ 5 · The Four Core Socket Syscallssocket → bind → listen → accept
For a TCP server, the kernel-level recipe is always the same:
socket() → bind() → listen() → accept() → read() / write() → close()
Arrow flow with kernel state transitions
socket(AF_INET, SOCK_STREAM, 0) │ returns fd (e.g. 17), state = UNCONNECTED ▼ bind(fd, 0.0.0.0:3000) │ associates local address/port with the socket ▼ listen(fd, backlog=511) │ state → LISTEN; kernel allocates SYN + accept queues ▼ accept(fd) ← may block, OR return EAGAIN if non-blocking │ returns NEW fd (e.g. 18) for the accepted connection ▼ read(18, buf, n) ← read bytes from the connection's recv buffer write(18, buf, n) ← write bytes into the connection's send buffer │ ▼ close(18) ← FIN/ACK exchange tears down the connection
How those syscalls map onto kernel state
┌──────────────────────────────────────────────────────────────┐ │ Node Process │ │ │ │ fd 17 ─────┐ │ │ ▼ │ │ ┌───────────────────────────┐ │ │ │ Listening socket │ │ │ │ state: LISTEN │ │ │ │ bound to 0.0.0.0:3000 │ │ │ │ │ │ │ │ SYN queue: [..] │ │ │ │ Accept queue: [c1,c2] │ │ │ └───────────┬───────────────┘ │ │ │ accept() pops c1 → │ │ fd 18 ─────┐│ │ │ ▼▼ │ │ ┌───────────────────────────┐ │ │ │ Accepted connection sock │ │ │ │ state: ESTABLISHED │ │ │ │ local: 192.168.1.5:3000 │ │ │ │ peer: 203.0.113.7:54321│ │ │ │ recv buf: <bytes> │ │ │ │ send buf: <bytes> │ │ │ └───────────────────────────┘ │ └──────────────────────────────────────────────────────────────┘
§ 6 · Non-blocking SocketsThe flag that makes the event loop possible
A socket fd has a flag: O_NONBLOCK. With it set, every syscall on that fd has a new contract:
- If the operation can be completed immediately, do it and return.
- If it would have to wait, don't sleep — return immediately with
EAGAIN/EWOULDBLOCK.
Non-blocking read()
read(fd, buf, n) on a non-blocking socket:
┌── data in recv buffer? ───── YES → copy bytes, return n
│
└───────────────────────────── NO → return -1, errno=EAGAIN
(do NOT sleep the thread)Compare to blocking read() (the Part 5 worker-pool model)
read(fd, buf, n) on a blocking fd: ┌── data ready? ─── YES → copy bytes, return n │ └────────────────── NO → BLOCK this thread until data arrives
Node sets all its TCP sockets to non-blocking. This is the foundational property that makes the whole event-loop model work:
EAGAIN, and Node uses a separate mechanism — epoll_wait — to find out when there will be something to do.§ 7 · Readiness APIsHow Node knows when there's something to do
If sockets are non-blocking and we never sleep on them directly, how does Node know when a socket has new data, or when a new connection has been accepted? Answer: the kernel provides a separate API whose entire purpose is "tell me which fds are ready."
On Linux that is epoll. Three syscalls:
epoll_create1()— make an epoll instance (itself an fd).epoll_ctl(EPOLL_CTL_ADD, fd, events)— register interest in events onfd.epoll_wait(timeout)— block this thread until at least one registered fd is ready (or the timeout expires).
An epoll instance, drawn
┌───────────────────────────────────────────────────────────┐ │ Kernel: epoll instance (fd 5) │ │ │ │ Interest list: │ │ ┌────────────────────────────────────────────────┐ │ │ │ fd 17 (listening) — wait for EPOLLIN (accept) │ │ │ │ fd 18 (conn A) — wait for EPOLLIN/EPOLLOUT │ │ │ │ fd 19 (conn B) — wait for EPOLLIN/EPOLLOUT │ │ │ │ fd 20 (eventfd) — wake-up signal │ │ │ └────────────────────────────────────────────────┘ │ │ │ │ Ready list (filled by kernel as events occur): │ │ ┌────────────────────────────────────────────────┐ │ │ │ fd 17 EPOLLIN ◀── new conn waiting in accept │ │ │ │ fd 19 EPOLLIN ◀── data ready in recv buf │ │ │ └────────────────────────────────────────────────┘ │ └───────────────────────────────────────────────────────────┘
When the main JS thread calls epoll_wait():
- If the ready list is empty → the thread is parked on the epoll wait queue (BLOCKED state).
- When the kernel later marks any registered fd as ready (e.g. a packet arrives, the handshake completes, send buffer drains), it wakes any threads on that epoll's wait queue.
epoll_waitreturns with the list of ready fds.
epoll_wait, waiting on all registered sockets and timers at once, not on any single one. That single sleep is how Node services thousands of connections with one thread.kqueue (macOS/BSD) and IOCP (Windows) differ in shape — IOCP is a completion API, not a readiness API — but libuv hides the difference. Mentally, on every platform, the model from Node's perspective is "tell me what's ready, on the main thread, in one call."
§ 8 · Why TCP Doesn't Use the Worker PoolThe single most important contrast with Part 5
Side-by-side comparison
┌─────────────────────────────┬──────────────────────────────┬────────────────────────────────┐ │ │ fs.readFile (Part 5) │ TCP socket I/O (this Part) │ ├─────────────────────────────┼──────────────────────────────┼────────────────────────────────┤ │ Underlying syscalls │ open/read/close (blocking) │ accept/read/write (NON-block.) │ │ Where blocking happens │ libuv WORKER thread blocks │ Nobody blocks; main thread │ │ │ in read() inside kernel │ sleeps in epoll_wait │ │ Worker pool involved? │ YES │ NO │ │ Readiness API involved? │ NO │ YES (epoll/kqueue/IOCP) │ │ Completion delivered via │ eventfd → epoll → main loop │ epoll directly tells main loop │ │ Main JS thread sleeps in │ epoll_wait │ epoll_wait │ │ │ (waiting on completion fd) │ (waiting on the sockets) │ └─────────────────────────────┴──────────────────────────────┴────────────────────────────────┘
Both paths end with the same result: the main JS thread wakes up from epoll_wait, finds out something is ready, runs C++ completion code, calls into V8, and your JS callback / event handler runs. But the cause of the wake-up is different. For files it's "a worker finished and poked the eventfd"; for sockets it's "the kernel saw a packet for fd 18 and marked it ready."
§ 9 · The Runtime Walkthrough BeginsBig-picture map before the deep dive
Now we trace the program concretely:
const net = require("net"); const server = net.createServer((socket) => { socket.on("data", (chunk) => { socket.write("you sent: " + chunk); }); }); server.listen(3000);
Big-picture map — main thread vs kernel
[MAIN JS THREAD] [LIBUV / OS / NETWORK]
your JS
│
▼
net.createServer(connectionListener)
│ creates Server (EventEmitter)
│ stores connectionListener as 'connection' handler
│
▼
server.listen(3000)
│
▼
Node JS layer → C++ TCPWrap → libuv uv_tcp_t
│
├─ socket(AF_INET, SOCK_STREAM | NONBLOCK) ──▶ kernel returns fd 17
├─ bind(17, 0.0.0.0:3000) ──▶ kernel binds
├─ listen(17, backlog) ──▶ kernel: state=LISTEN
├─ epoll_ctl(ADD, fd=17, EPOLLIN) ──▶ register w/ epoll
│
▼
listen() returns; main JS thread continues
│
▼
top-level JS finishes → call stack empty
│
▼
event loop tick:
poll phase → epoll_wait(...)
│
│ thread BLOCKS here ◀───── this is "Node idling"
│
▼ (time passes) client somewhere does connect()
│
▼
SYN → SYN-ACK → ACK
│
▼
kernel completes handshake,
puts conn on accept queue,
marks fd 17 as EPOLLIN-ready
▼
epoll_wait wakes
│
▼
libuv sees fd 17 ready → calls TCP "on connection" callback
│
▼
C++: accept(17) → new fd 18, set non-blocking
│
▼
C++: build new TCPWrap for fd 18, register with epoll for EPOLLIN
│
▼
JS layer: create new net.Socket wrapping that TCPWrap
│
▼
V8 invokes server emit('connection', socket) on main JS thread
│
▼
your connectionListener runs; you attach socket.on('data', ...)
│
▼
control returns to event loop
... later, client sends bytes ...
NIC RX → kernel TCP/IP →
fd 18 recv buffer fills
kernel marks fd 18 EPOLLIN
▼
epoll_wait wakes
│
▼
libuv calls StreamWrap onread → read(18, buf, n)
│
▼
C++ allocates Buffer, V8 emit('data', buf) on main JS thread
│
▼
your handler runs: socket.write("you sent: " + chunk)
│
▼
JS write → C++ → libuv → write(18, ...) → kernel send buffer
│
▼
kernel TCP/IP → NIC TX → wireThat whole trace is what the rest of this lesson expands.
Interactive · 12-Step Trace Stepper
Step through the full net.createServer lifecycle. Watch which actor (main JS thread, kernel softirq, NIC) owns each step — and notice that the worker pool never appears.
§ 10 · Step 1 — net.createServer(connectionListener)Pure JavaScript. Nothing has touched the kernel.
net.createServer is pure JavaScript. It returns a Server object — an EventEmitter — and registers your connectionListener as a listener for the 'connection' event.
function createServer(opts, listener) { return new Server(opts, listener); } class Server extends EventEmitter { constructor(opts, listener) { super(); if (typeof listener === "function") this.on("connection", listener); // ... lots more setup ... } }
At this moment, no socket exists yet. Nothing has touched the kernel. We just have a JS object in V8's heap with a listener attached.
MAIN JS THREAD
JS call stack:
┌────────────────────────────────────────┐
│ net.createServer(connectionListener) │
├────────────────────────────────────────┤
│ <top-level module code> │
└────────────────────────────────────────┘
V8 heap:
┌────────────────────────────────────────┐
│ Server (EventEmitter) │
│ _events: { │
│ connection: connectionListener ───┼──▶ JS function object
│ } │
│ _handle: null (no native yet) │
└────────────────────────────────────────┘listen() is called, a net.Server is just a JS object. There is no fd, no kernel socket, no port reservation, no libuv handle.§ 11 · Step 2 — server.listen(3000) Enters JSWhere the real work begins
listen() is where the real work begins. The JS layer (lib/net.js):
- Validates the port / host / options.
- Creates a
TCPWrap— Node's C++ object that wraps auv_tcp_t. - Calls into the binding to actually create the OS socket, bind, and listen.
JS → C++ → libuv flow
MAIN JS THREAD server.listen(3000) │ ▼ parse args (port=3000, host=0.0.0.0, backlog=511) │ ▼ new TCPWrap() ← creates uv_tcp_t but no fd yet │ ▼ this._handle = TCPWrap │ ▼ binding → uv_tcp_bind / uv_listen │ ▼ register on connection callback (C++)
Object snapshot — JS heap, C++, libuv heap
┌────────────────────────────────────────────────┐
│ MAIN JS THREAD — V8 heap │
│ │
│ ┌──────────────────────┐ │
│ │ Server (JS) │ │
│ │ _handle ───────────────┐ │
│ │ _events.connection │ │ │
│ └──────────────────────┘ │ │
│ ▼ │
│ ┌──────────────────────────────────────────┐ │
│ │ TCPWrap (C++ object exposed to JS) │ │
│ │ onconnection: <C++ trampoline> ────────┼─┼─▶ calls back into JS
│ │ uv_tcp_t handle ─────────┐ │ │
│ └────────────────────────────┼─────────────┘ │
└────────────────────────────────┼───────────────┘
▼
┌────────────────────────────────────────────────┐
│ libuv heap │
│ uv_tcp_t │
│ fd: -1 (not yet) │
│ loop: pointer to uv_loop_t │
│ io_watcher: uv__io_t │
│ connection_cb: ConnectionWrap::OnConnection│
└────────────────────────────────────────────────┘TCPWrap (C++) is the bridge. It owns the uv_tcp_t, holds a persistent reference to the JS Server object, and has C++ trampoline functions that V8 will call when libuv reports events. The JS object knows about it via _handle.§ 12 · Step 3 — socket(), bind(), listen()Three syscalls, all on the main JS thread
The C++ binding now issues the three syscalls in order. All of this happens on the main JS thread, synchronously, inside server.listen(). None of it is delegated to a worker.
socket()
main JS thread → libuv → C runtime → syscall: socket(AF_INET, SOCK_STREAM, 0) Kernel: - allocate struct socket / struct sock - mark state UNCONNECTED - allocate slot in this process's fd table - return fd = 17
libuv then makes the fd non-blocking with fcntl(17, F_SETFL, O_NONBLOCK) and sets SO_REUSEADDR.
bind()
syscall: bind(17, sockaddr{ family=AF_INET, addr=0.0.0.0, port=3000 })
Kernel:
- check that 0.0.0.0:3000 is not already bound by another socket
(without SO_REUSEADDR, that's a hard failure)
- record local addr/port on the struct sockIf another process already owns port 3000, this is the moment you get EADDRINUSE — which surfaces in JS as the famous Error: listen EADDRINUSE.
listen()
syscall: listen(17, backlog=511) Kernel: - transition struct socket state → LISTEN - allocate the SYN queue and accept queue - now this socket can receive SYN packets
Kernel state after listen()
┌──────────────────────────────────────────┐
│ struct socket (fd 17) │
│ state: LISTEN │
│ sk → ┐ │
└────────┼─────────────────────────────────┘
▼
┌──────────────────────────────────────────┐
│ struct sock (listening) │
│ local: 0.0.0.0:3000 │
│ SYN queue: [ ] │
│ Accept queue: [ ] │
│ accept queue max: 511 │
└──────────────────────────────────────────┘listen(), the kernel can complete TCP handshakes for incoming SYNs without your process doing anything. It puts the resulting fully-established connections on the accept queue. Your application only finds out about them later, when it calls accept().§ 13 · Step 4 — Register fd 17 with epollTell the kernel: "wake me when this is ready"
Right after listen(), libuv registers the listening socket with its poll backend:
epoll_ctl(epfd, EPOLL_CTL_ADD, fd=17,
{ events: EPOLLIN, data: pointer-to-uv__io_t })Now the kernel knows: "if fd 17 ever becomes readable (i.e. a connection is ready to be accepted), put it on the ready list of this epoll instance."
┌────────────────────────────────────────────────────┐ │ epoll instance (epfd) │ │ │ │ Interest list: │ │ fd 17 (LISTEN) → EPOLLIN │ │ fd N (eventfd, for worker-pool wakeups) │ │ │ │ Ready list: │ │ (empty) │ └────────────────────────────────────────────────────┘
server.listen(3000) then returns to JS. The main JS thread continues, the top-level code finishes, the JS call stack empties — and the event loop takes over.
§ 14 · Step 5 — The Event Loop Blocks in epoll_wait"Node idle" looks exactly like this
This is the critical moment. With nothing else to do:
event loop iteration:
timers phase — none due
pending callbacks — none
idle / prepare — none
poll phase — epoll_wait(epfd, events, max, timeout)
│
▼
no fds ready → main JS thread BLOCKS hereProcess snapshot at this instant
┌─────────────────────────────────────────────────────────────┐
│ NODE PROCESS │
│ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ Main JS thread │ │
│ │ state: BLOCKED in epoll_wait │ │
│ │ waiting on: │ │
│ │ - fd 17 (listening socket) EPOLLIN │ │
│ │ - eventfd (worker-pool completions) │ │
│ │ call stack: <inside libuv uv_run → poll> │ │
│ └───────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ Worker pool threads │ │
│ │ (idle, no jobs) │ │
│ └───────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Kernel │
│ listening socket fd 17, state LISTEN │
│ accept queue: empty │
│ network stack: ready to receive SYNs on port 3000 │
└─────────────────────────────────────────────────────────────┘epoll_wait, and the OS scheduler isn't giving it any CPU at all. The CPU is free for other processes. Node uses 0% CPU in this state.§ 15 · Step 6 — A Client ConnectsThe kernel does the entire handshake alone
Now imagine a client somewhere does connect("your-ip", 3000). From the moment SYN arrives at your NIC:
NIC RX Kernel softirq Listening sock fd 17 │ │ │ ├── packet (SYN) ──▶ ring buffer │ │ │ │ │ │ hardware IRQ → softirq scheduled │ │ │ ▼ │ │ net_rx → IP layer → TCP layer │ │ │ │ │ ├── lookup: who listens on 3000? │ │ │ → fd 17 │ │ │ │ │ ├── allocate request_sock │ │ │ add to SYN queue ──────────────▶│ │ │ send SYN-ACK │ │ │ │ │ ... ACK arrives later ... │ │ │ │ │ │ ├── handshake complete │ │ │ create new struct sock (child) │ │ │ move from SYN queue → accept ──▶│ │ │ queue │ │ │ │ │ ├── mark fd 17 EPOLLIN ready ──────▶│ │ │ wake any threads on epoll's │ │ │ wait queue
Kernel state right after handshake completes
┌──────────────────────────────────────────┐
│ Listening socket fd 17 │
│ state: LISTEN │
│ SYN queue: [ ] │
│ Accept queue: [ child_sock ] ◀── new │
└──────────────────────────────────────────┘
│
▼
┌──────────────────────────┐
│ child_sock │
│ state: ESTABLISHED │
│ local: 192.168.1.5:3000│
│ peer: 203.0.113.7:54321│
│ recv buf: empty │
│ send buf: empty │
│ (no fd yet — accept() │
│ will allocate one) │
└──────────────────────────┘§ 16 · Step 7 — epoll_wait Returnslibuv runs the connection callback
The kernel marking fd 17 EPOLLIN-ready wakes the main JS thread out of epoll_wait:
MAIN JS THREAD
(was BLOCKED in epoll_wait)
│
▼ (kernel wakes thread)
epoll_wait returns events = [ { fd: 17, EPOLLIN } ]
│
▼
libuv loops over the events:
for each event → call the registered uv__io_t callback
│
▼
For fd 17 the callback is uv__server_io
│
▼
loop: while (accept queue not empty) {
accept(17, ...) ← non-blocking accept
if EAGAIN → break
call connection_cb (which is C++ ConnectionWrap::OnConnection)
}This is "edge-triggered-style draining": libuv accepts in a loop until the kernel says EAGAIN, so a single epoll wake-up can deliver many connections.
Native call stack on the main JS thread
┌──────────────────────────────────────────────────────────┐ │ MAIN JS THREAD │ │ │ │ Native call stack (no JS frames yet): │ │ ┌────────────────────────────────────────────────┐ │ │ │ ConnectionWrap::OnConnection │ │ │ ├────────────────────────────────────────────────┤ │ │ │ uv__server_io │ │ │ ├────────────────────────────────────────────────┤ │ │ │ uv__io_poll (epoll backend) │ │ │ ├────────────────────────────────────────────────┤ │ │ │ uv_run (event loop) │ │ │ ├────────────────────────────────────────────────┤ │ │ │ Node entry point │ │ │ └────────────────────────────────────────────────┘ │ └──────────────────────────────────────────────────────────┘
We are still on the same thread that ran your top-level code. Only the kind of work has changed.
§ 17 · Step 8 — accept() Produces fd 18Each connection gets its own fd
The non-blocking accept() syscall
syscall: accept4(17, &peer_addr, &len, SOCK_NONBLOCK)
Kernel:
- if accept queue empty: return -EAGAIN
- else:
pop child_sock from accept queue
allocate new fd in process fd table → 18
return 18 + peer_addr filled inNow the process has a brand-new fd:
┌──────────────────────────────────────────┐ │ Process fd table │ │ 17 → listening socket (LISTEN) │ │ 18 → new connection (ESTABLISHED) ◀── │ └──────────────────────────────────────────┘
libuv then:
- Wraps fd 18 in a new
uv_tcp_thandle. - Calls
epoll_ctl(EPOLL_CTL_ADD, 18, EPOLLIN)so the kernel will tell us when this connection has data. - Builds a new
TCPWrapJS-visible object around it. - Calls into JS to emit
'connection'.
┌────────────────────────────────────────────────────┐ │ epoll interest list (after) │ │ fd 17 (LISTEN) EPOLLIN │ │ fd 18 (ESTABLISHED) EPOLLIN ◀── new │ │ fd N (eventfd) │ └────────────────────────────────────────────────────┘
uv_tcp_t, its own TCPWrap, and its own net.Socket in JS. The listening socket (fd 17) is never used to read or write data — it only spawns children.§ 18 · Step 9 — Crossing into JavaScriptThe 'connection' event
Now the C++ code calls into V8 to emit 'connection'. This is exactly the same kind of crossing as in Part 5's AfterRead → cb path: native code, on the main JS thread, asks V8 to run a JS function.
Step by step
ConnectionWrap::OnConnection (C++)
│
▼
build a JS net.Socket wrapping the new TCPWrap
│
▼
V8: server.emit('connection', socket)
│
▼
EventEmitter dispatch (JS)
│
▼
your connectionListener(socket) runs
│
▼
socket.on('data', dataHandler) ← attach handlerObject relationships after 'connection'
┌────────────────────────────────────────────────────┐ │ V8 heap │ │ │ │ ┌────────────────┐ ┌──────────────────────┐ │ │ │ Server (JS) │ │ net.Socket (JS) │ │ │ │ _handle ──────┼─┐ │ _handle ────────────┼─┐ │ │ │ _events │ │ │ _events.data ───────┼─┼─▶ your dataHandler │ └────────────────┘ │ └──────────────────────┘ │ │ │ ▼ ▼ │ │ ┌────────────────────────┐ ┌────────────────────┴──┐ │ │ TCPWrap (fd 17) │ │ TCPWrap (fd 18) │ │ │ uv_tcp_t (listening) │ │ uv_tcp_t (connected) │ │ └────────────────────────┘ │ onread: <C++ tramp> │ │ └───────────────────────┘ └────────────────────────────────────────────────────────┘
Main JS thread call stack at this exact moment
┌────────────────────────────────────┐
│ dataHandler attach (your code) │ ← if you do socket.on('data', ...)
├────────────────────────────────────┤ synchronously inside listener
│ connectionListener(socket) │
├────────────────────────────────────┤
│ V8 emit('connection') trampoline │
├────────────────────────────────────┤
│ ConnectionWrap::OnConnection │
├────────────────────────────────────┤
│ uv__server_io │
├────────────────────────────────────┤
│ uv__io_poll │
├────────────────────────────────────┤
│ uv_run │
└────────────────────────────────────┘When your listener returns, control unwinds back into libuv, which continues draining the accept queue (in case multiple connections arrived), then proceeds to the next event in the ready list, then back to the next loop iteration.
§ 19 · Step 10 — Reading DataNIC → kernel → JS Buffer
Now the connection is established, JS has an event handler, and the main thread goes back to epoll_wait. Eventually the client sends bytes:
WIRE NIC KERNEL
│ │
TCP segment ──▶ frame ───────────┤ │
│ DMA → RX ring buffer │
│ raise hardware IRQ ─────────│
│ ▼
│ softirq runs
│ net_rx → IP → TCP
│ │
│ ├ match (peer,port)→ fd 18
│ ├ append bytes to
│ │ recv buffer of sock18
│ ├ mark fd 18 EPOLLIN ready
│ └ wake threads on epoll wqKernel state after data lands
┌──────────────────────────────────────────┐ │ struct sock (fd 18) │ │ state: ESTABLISHED │ │ recv buffer: [ "hello\n" ] ◀── new │ │ send buffer: empty │ └──────────────────────────────────────────┘
Main JS thread wakes from epoll_wait
MAIN JS THREAD
epoll_wait returns events = [ { fd: 18, EPOLLIN } ]
│
▼
libuv: lookup uv__io_t for fd 18 → call its read callback
│
▼
StreamWrap::OnUVRead path:
while (true) {
buf = allocate Buffer (via JS allocator)
n = read(18, buf, n)
if (n > 0) emit 'data' with buf
if (n == 0) emit 'end'
if (n < 0 && errno == EAGAIN) break
}The read(18, ...) syscall on a non-blocking socket
syscall: read(18, buf, n) Kernel: - check recv buffer for sock 18 - HAS data → copy bytes to user buf, return n - empty → return -EAGAIN (do not block)
Then C++ asks V8 to emit 'data' with the Buffer — same machinery as Part 5's cb(err, data).
ConnectionWrap/StreamWrap (C++)
│
▼
V8: socket.emit('data', buffer)
│
▼
your dataHandler(chunk)
│
▼
socket.write("you sent: " + chunk)Buffer you receive in 'data' is a JS object whose backing storage is a chunk of native memory that libuv handed read(). It is the same boundary mechanism as the Part 5 file Buffer, just with different bytes flowing through it.§ 20 · Step 11 — Writing DataJS → kernel send buffer → NIC
socket.write("you sent: hello\n") runs on the main JS thread. Path:
JS: socket.write(str) │ ▼ JS layer: convert string to Buffer, push into internal write queue │ ▼ C++ binding: StreamBase::Write │ ▼ libuv: uv_try_write → write(18, buf, n) (non-blocking) │ ▼ Kernel: - copy bytes into send buffer of sock 18 - if send buffer can accept all → return n - if partial / full → return short count or EAGAIN │ ▼ TCP layer schedules segments │ ▼ IP layer + driver → TX ring buffer → NIC → wire
If the send buffer fills up (slow client, lossy network), the kernel returns EAGAIN for the leftover. libuv stores the unwritten bytes inside the uv_tcp_t and registers interest in EPOLLOUT for fd 18. When the kernel later drains enough of the send buffer to accept more bytes, it marks fd 18 EPOLLOUT-ready, the main thread wakes from epoll_wait, libuv finishes the write, and only then does it call your 'drain' event / completion callback.
Asymmetric send state — backpressure
┌──────────────────────────────────────────┐
│ struct sock (fd 18) │
│ send buffer: [........FULL............]│
│ peer's recv window: small │
└──────────────────────────────────────────┘
▲
│ libuv has pending bytes:
┌───────────┴──────────────────────────────┐
│ uv_tcp_t (fd 18) │
│ write_queue: │
│ [ unflushed JS Buffer remnants ] │
│ interested in EPOLLOUT │
└──────────────────────────────────────────┘write() returning EAGAIN, and Node holding your bytes in user space until EPOLLOUT says there's room. That's the entire mechanism behind the 'drain' event and the false return value of socket.write().§ 21 · Step 12 — ClosingFIN/ACK and the close callback
When the client sends FIN, the kernel:
- copies any remaining bytes into the recv buffer
- marks the recv side of sock 18 closed
- delivers EOF on next
read()(returns 0) - marks fd 18 EPOLLIN-ready
Close path
NIC (FIN segment) → kernel TCP → sock 18 state: CLOSE_WAIT
recv buffer ends with EOF
fd 18 EPOLLIN ready
│
▼
main JS thread wakes from epoll_wait
│
▼
read(18, ...) returns 0
│
▼
StreamWrap → emit('end') in JS
│
▼
when JS calls socket.end():
│
▼
shutdown(18, SHUT_WR) → kernel sends our FIN
│
▼
peer ACKs → state: TIME_WAIT (or directly closed)
│
▼
close(18) → fd freed, struct sock eventually freed
│
▼
emit('close') in JSTCP state walk on our side
┌──────────┐ recv FIN ┌────────────┐ send FIN ┌────────────┐ ACK ┌──────────┐ │ESTABLISH │ ──────────▶ │ CLOSE_WAIT │ ────────────▶ │ LAST_ACK │ ─────▶ │ CLOSED │ └──────────┘ └────────────┘ └────────────┘ └──────────┘
§ 22 · The Critical DiagramThe TCP analogue of Part 5's center diagram — memorize it
This is the analogue of Part 5's center diagram, but for sockets. Memorize it.
MAIN JS THREAD KERNEL / NIC
server.listen(3000)
│
├── socket() → fd 17
├── bind() → 0.0.0.0:3000
├── listen() → state LISTEN, queues allocated
├── epoll_ctl(ADD, 17, EPOLLIN) ───────────▶ kernel: fd 17 watched
│
listen() returns
│
event loop runs poll phase
│
▼
epoll_wait(...) (BLOCKED here) ◀── this is "Node idle"
│ │
│ client connects
│ │
│ SYN/SYN-ACK/ACK
│ handshake (no JS involvement)
│ │
│ conn pushed to accept queue
│ fd 17 marked EPOLLIN
│ ◀──────────── kernel wakes thread ──────────────┘
▼
libuv: drain accept queue
├── accept(17) → fd 18 (non-blocking)
├── new uv_tcp_t for fd 18
├── epoll_ctl(ADD, 18, EPOLLIN)
├── new net.Socket in JS
└── server.emit('connection', socket)
│
your connectionListener runs
your socket.on('data', ...) runs
│
▼
back to event loop
│
▼
epoll_wait(...) again ◀── client sends bytes
│ │
│ NIC → kernel TCP
│ → fd 18 recv buffer
│ → mark fd 18 EPOLLIN
│ ◀──────────── kernel wakes thread ──────────────┘
▼
libuv: read(18, buf, n) loop until EAGAIN
├── allocate JS Buffer
├── copy bytes
└── socket.emit('data', chunk)
│
your dataHandler runs
│
▼
socket.write(...) → write(18, ...) into send buffer
│
▼
back to event loop
│
... and so on until FIN/close ...§ 23 · Annotated Owner TableEvery step, every thread
| Step | Thread / Owner |
|---|---|
net.createServer(listener) | Main JS · V8 |
Build Server JS object | Main JS · V8 |
server.listen(3000) JS layer | Main JS |
Build TCPWrap / uv_tcp_t | Main JS · C++ |
socket() syscall | Main JS → kernel |
bind() syscall | Main JS → kernel |
listen() syscall | Main JS → kernel |
epoll_ctl(ADD, fd17) | Main JS → kernel |
| Top-level JS finishes | Main JS |
| Event loop enters poll phase | Main JS |
epoll_wait (sleeping) | Main JS BLOCKED |
| TCP handshake for incoming SYN | Kernel softirq + NIC IRQ |
| Connection placed on accept queue | Kernel |
| fd 17 marked EPOLLIN-ready | Kernel |
Main thread wakes from epoll_wait | Main JS · RUNNABLE |
accept(17) → fd 18 | Main JS → kernel |
Build TCPWrap for fd 18 | Main JS · C++ |
epoll_ctl(ADD, fd18) | Main JS → kernel |
Build net.Socket JS object | Main JS · V8 |
server.emit('connection', socket) | Main JS · V8 |
Your connectionListener runs | Main JS · V8 |
socket.on('data', ...) | Main JS · V8 |
| Bytes arriving at NIC | NIC + softirq |
| Bytes copied to fd 18 recv buffer | Kernel |
| fd 18 EPOLLIN-ready | Kernel |
| Main thread wakes again | Main JS |
read(18, ...) (non-blocking) | Main JS → kernel |
Build JS Buffer | Main JS · V8 |
socket.emit('data', chunk) | Main JS · V8 |
| Your data handler runs | Main JS · V8 |
socket.write(...) | Main JS → send buf |
| Bytes leaving on NIC | Kernel + NIC TX |
FIN handling, close(18) | Main JS → kernel |
'close' emit | Main JS · V8 |
Final ownership map (the Part 8 analogue of Part 5's §33 box)
┌──────────────────────────────────────────────────────────────────────────┐
│ NODE PROCESS │
│ │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ Main JS Thread │ │
│ │ - all JS execution │ │
│ │ - net.createServer / Server / Socket JS objects │ │
│ │ - C++ TCPWrap / StreamWrap / ConnectionWrap │ │
│ │ - libuv event loop (poll phase calls epoll_wait) │ │
│ │ - issues socket/bind/listen/accept/read/write syscalls │ │
│ │ - V8 emits 'connection' / 'data' / 'end' / 'close' │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ Libuv Worker Thread Pool │ │
│ │ * NOT used for plain TCP I/O * │ │
│ │ (only relevant later: DNS getaddrinfo, crypto, zlib, fs) │ │
│ └────────────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────────────┐
│ OS KERNEL │
│ epoll instance · TCP/IP stack · listen+accept queues │
│ per-socket recv & send buffers · TCP state machines · timers │
│ network softirqs · scheduler │
└──────────────────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────────────┐
│ HARDWARE │
│ NIC (RX/TX ring buffers, DMA, interrupts) · physical link │
└──────────────────────────────────────────────────────────────────────────┘§ 24 · Wrong Mental Model vs CorrectSix wrong takes — and what's actually true
net.createServer spawns a thread per connection.epoll/kqueue/IOCP, and one thread services them all by reacting to readiness events.accept/read/write go through libuv's event loop + readiness API. The worker pool is for things the kernel can't do non-blockingly well — filesystem ops, DNS, crypto, compression. Don't conflate the two paths.'connection' event fires when a SYN arrives.epoll_wait, drained the accept queue, allocated a new fd, built a net.Socket, and emitted the event.socket.on('data', cb) means cb fires for every TCP segment.'data' whenever a read() returns some bytes. One 'data' may correspond to many segments coalesced in the recv buffer; one segment may be split across multiple 'data' events. Never assume message framing.epoll_wait. It uses 0% CPU when idle. The kernel wakes it precisely when something is ready. This is the entire reason Node is efficient at high connection counts.socket.write sends bytes to the network.socket.write copies bytes into the kernel's send buffer for that connection. The kernel decides when to actually transmit them, in what segments, with what timing, and handles retransmits. From your code's perspective, you're filling a buffer, not driving the wire.Correct mental model
JS world: starts the server, attaches event handlers, calls write()
Native/libuv: runs epoll_wait on the main thread, dispatches readiness
events, performs non-blocking syscalls, copies bytes into
JS Buffers and back
OS kernel: owns the listening socket, the SYN/accept queues, all
connection sockets, all recv/send buffers, all TCP state,
all retransmits, the entire 3-way handshake
NIC + driver: physically transfers bytes; raises interrupts when packets
arrive
Main JS thread: the only thread executing your JS; also the thread that
sleeps in epoll_wait; never blocks on any single socketepoll_wait. That single sleeping thread, plus the kernel doing all the heavy lifting, is what gives Node its concurrency.§ 25 · CPU-bound vs I/O-bound — TCP editionNon-blocking doesn't make CPU-bound JS faster
The same warning from Part 5 applies, just sharper:
Time ────────────────────────────────────────────────────►
Main JS thread:
[A connect] [B connect] [BIG SYNC LOOP ......................] [A data] [B data]
Kernel:
[handshake A] [handshake B] [bytes arriving for A ...........]
[handshake B] [bytes arriving for B ...........]
[send buffers backing up ........]While your handler does a 5-second synchronous computation:
- new connections still complete in the kernel (handshakes don't need your code)
- they pile up on the accept queue, which has a limit (the
backlog) - once that queue fills, the kernel drops or refuses additional SYNs
- existing connections accumulate bytes in their recv buffers; if those fill, TCP flow control closes the receive window and the peer is told to wait
- nothing in JS happens until the loop ends
The main JS thread is still single-threaded. The kernel is buffering everything for you, but its buffers are finite.
§ 26 · The AnalogyThe reception desk · the phone exchange · the mailroom
If fs.readFile is "customer / cashier / warehouse worker," then net.createServer is something different: nobody walks anywhere. There is one bored receptionist, an enormous switchboard, and a back-office mailroom that handles every envelope the post office delivers — without ever asking the receptionist for permission.
Receptionist (Main JS thread) └─ sleeps at the desk; one switchboard alarm wakes her Switchboard (epoll / kqueue / IOCP) └─ thousands of phone lines, one alarm light per ready line Mailroom (Kernel TCP/IP stack + NIC) └─ handles every envelope, fills mailboxes, rings the alarm
The trick: the receptionist never walks to the mailroom. The mailroom hands her envelopes through a slot at her desk (the recv buffer). She doesn't have to know which line a letter came in on — the switchboard's alarm light tells her exactly which mailbox has new mail. She processes one letter, glances at the board for any other lit alarms, then goes back to sleep.
That's the entire concurrency model. Worker threads don't appear in this picture at all — there's no "warehouse worker" because there's nothing physical to fetch. The kernel and NIC do all the actual work; the receptionist just reads what's been deposited and writes replies into outgoing mailboxes.
§ 27 · Final Compressed MapThe whole journey, top to bottom and back
[JavaScript]
net.createServer(listener).listen(3000)
│
[Node JS layer] (main JS thread)
Server, Socket, EventEmitter
│
[Node C++ binding]
TCPWrap, ConnectionWrap, StreamWrap
│
[libuv]
uv_tcp_t handle, uv__io_t watcher, uv_loop_t event loop
│
[OS readiness API]
epoll (Linux) / kqueue (BSD/macOS) / IOCP (Windows)
│
[Kernel TCP/IP stack]
socket()/bind()/listen()/accept()
SYN queue + accept queue (listening socket)
per-connection recv & send buffers
TCP state machine, timers, retransmits
│
[Driver + NIC]
RX/TX ring buffers, DMA, hardware interrupts → softirqs
│
[Wire / Internet]
bytes
│
─────── readiness flows back up ───────
│
[Kernel]
marks fd EPOLLIN/EPOLLOUT ready
│
[Main JS thread, in epoll_wait]
wakes; libuv looks up the watcher; drains via non-blocking read()/accept()
│
[C++ binding → V8]
build JS Socket / Buffer; emit('connection') / emit('data') / emit('end')
│
[your JS]
handler runs on main JS thread§ 28 · Final "Lock This In" SummaryThe whole story, six ways
net.createServer server is, at the OS level: one listening socket fd, plus N accepted connection fds, all registered with one epoll/kqueue/IOCP instance, all serviced by one main JS thread that sleeps in epoll_wait between events.fs.readFile is exact and important: filesystem ops use the worker pool because syscalls can block; socket ops use readiness APIs because the kernel can tell you when they won't block.'connection', the connection has been ESTABLISHED for some time and is sitting in the accept queue waiting for the main thread to wake from epoll_wait and drain it.'data' events do not correspond to TCP segments. They correspond to "the kernel said fd is readable, so libuv called read() and got these bytes." TCP is a byte stream; framing is your job.socket.write() doesn't transmit. It copies into the kernel send buffer. Backpressure is the moment that buffer is full — write() returns EAGAIN, libuv waits for EPOLLOUT, and Node tells you false so you can pause.- JavaScript creates the server and registers handlers.
- The main JS thread issues
socket/bind/listen/epoll_ctlsyscalls. - The kernel owns all TCP state and completes handshakes alone.
- The main JS thread sleeps in
epoll_waituntil the kernel signals readiness. - libuv drains ready fds with non-blocking syscalls.
- V8 emits
'connection'/'data'/'end'/'close'on the main JS thread.
That is the full net.createServer story from your JavaScript through the kernel TCP/IP stack, the NIC, and back up to your event handlers — without ever touching a worker thread.