Mission 08 · Node.js Internals Campaign

net.createServer() — sockets, kernel queues, readiness, and the event loop

The second lesson. Part 5 (fs.readFile) built the foundation: CPU → process → thread → syscall → libuv → kernel. This part takes that same skeleton and changes one critical thing — the path through libuv is not the worker pool anymore. Sockets do not block worker threads. The main JS thread itself sleeps in epoll_wait.

29 sections48 figuresreadiness, not the pool

§ 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:

JavaScript the program we trace
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:

  1. The main JS thread is the only thread executing your JavaScript.
  2. That same main JS thread is also the thread sleeping in epoll_wait (or kqueue / IOCP) waiting for socket events.
  3. No libuv worker thread is involved in plain TCP I/O — not for accept, not for read, not for write.
!
Lock this in
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)

▸ ownership-chain.txtflow · top-down
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

▸ ownership-chain-boxed.txtlayered stack
┌──────────────────────────────────────────────┐
│ 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.

▸ instruction-cycle.txtcycle
        ┌──────────┐
        │  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.

!
Lock this in
When a packet "arrives," what physically happens is: the NIC raises an interrupt, the CPU jumps into a kernel interrupt handler, and that handler is just more machine code running on some thread (often a softirq context). There is no "network event" the CPU directly perceives.

§ 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

▸ fd-table.txtprocess-local handles
┌─────────────────────────────────────────────────┐
│ 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.txtkernel-side state
┌──────────────────────────────────────────────┐
│ 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)   │
└──────────────────────────────────────────────┘
!
Lock this in
An fd like 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:

  1. Connections are explicit. A TCP connection is a 4-tuple (local IP, local port, peer IP, peer port) that both ends agree exists.
  2. The 3-way handshake establishes the connection: SYN → SYN-ACK → ACK.
  3. 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

▸ tcp-handshake.txt3-way
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.

▸ listen-queues.txtSYN + accept
┌────────────────────────────────────────────────────┐
│ 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   │   │
│                             └──────────────────┘   │
└────────────────────────────────────────────────────┘
!
Key invariant
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:

syscalls server lifecycle
socket()  →  bind()  →  listen()  →  accept()  →  read() / write()  →  close()

Arrow flow with kernel state transitions

▸ syscall-flow.txtserver side
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

▸ kernel-state-mapping.txttwo sockets
┌──────────────────────────────────────────────────────────────┐
│ 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>       │                              │
│   └───────────────────────────┘                              │
└──────────────────────────────────────────────────────────────┘
!
Lock this in
A listening socket and an accepted connection socket are different kernel objects with different fds. The listening socket only ever produces new connections. The accepted socket is what you actually read from and write to.

§ 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()

▸ nonblock-read.txtO_NONBLOCK set
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)

▸ blocking-read.txtworker-thread 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:

!
Lock this in
Node never lets the main JS thread sit inside a blocking socket syscall. Every socket fd is non-blocking. When there's nothing to do, the kernel returns 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 on fd.
  • epoll_wait(timeout) — block this thread until at least one registered fd is ready (or the timeout expires).

An epoll instance, drawn

▸ epoll-instance.txtinterest list + ready list
┌───────────────────────────────────────────────────────────┐
│ 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_wait returns with the list of ready fds.
!
Lock this in
The main JS thread does sleep — but it sleeps inside 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-vs-tcp.txttwo paths through libuv
┌─────────────────────────────┬──────────────────────────────┬────────────────────────────────┐
│                             │ 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."

!
Internalize this contrast
Same Node, same libuv, same main JS thread. 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. Don't conflate the two paths.

§ 9 · The Runtime Walkthrough BeginsBig-picture map before the deep dive

Now we trace the program concretely:

JavaScript our server
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

▸ trace-overview.txttwo columns
[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 → wire

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

step 0 / 18
Main JS Thread
Kernel · NIC

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

lib/net.js (simplified) JS only
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.

▸ heap-snapshot-after-createServer.txtJS only
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)      │
└────────────────────────────────────────┘
!
Lock this in
Until 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):

  1. Validates the port / host / options.
  2. Creates a TCPWrap — Node's C++ object that wraps a uv_tcp_t.
  3. Calls into the binding to actually create the OS socket, bind, and listen.

JS → C++ → libuv flow

▸ listen-flow.txtmain JS thread
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

▸ object-snapshot-listen.txtthree layers
┌────────────────────────────────────────────────┐
│ 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│
└────────────────────────────────────────────────┘
!
Lock this in
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()

▸ socket-syscall.txtkernel side
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()

▸ bind-syscall.txtkernel side
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 sock

If another process already owns port 3000, this is the moment you get EADDRINUSE — which surfaces in JS as the famous Error: listen EADDRINUSE.

listen()

▸ listen-syscall.txtkernel side
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()

▸ state-after-listen.txtfd 17, state 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                  │
└──────────────────────────────────────────┘
!
Lock this in
After 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:

syscall register interest
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-after-register.txtinterest list updated
┌────────────────────────────────────────────────────┐
│ 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-blocks.txtpoll phase
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 here

Process snapshot at this instant

▸ process-snapshot-idle.txt0% CPU
┌─────────────────────────────────────────────────────────────┐
│ 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         │
└─────────────────────────────────────────────────────────────┘
!
Lock this in
"Node is idle" looks exactly like this: the main JS thread is parked inside one syscall, 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:

▸ handshake-flow.txtNIC → softirq → fd 17
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

▸ state-after-handshake.txtchild sock on accept queue
┌──────────────────────────────────────────┐
│ 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)    │
                         └──────────────────────────┘
!
Lock this in
Three-way handshakes happen in the kernel, in interrupt and softirq context, completely independently of your Node process. By the time JavaScript hears about a connection, the connection has been established for some time already.

§ 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:

▸ libuv-wakes.txtmain JS thread
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

▸ native-stack.txtno JS frames yet
┌──────────────────────────────────────────────────────────┐
│ 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

▸ accept-syscall.txtkernel side
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 in

Now the process has a brand-new fd:

▸ fd-table-after-accept.txtprocess state
┌──────────────────────────────────────────┐
│ Process fd table                         │
│   17 → listening socket (LISTEN)         │
│   18 → new connection (ESTABLISHED) ◀──  │
└──────────────────────────────────────────┘

libuv then:

  1. Wraps fd 18 in a new uv_tcp_t handle.
  2. Calls epoll_ctl(EPOLL_CTL_ADD, 18, EPOLLIN) so the kernel will tell us when this connection has data.
  3. Builds a new TCPWrap JS-visible object around it.
  4. Calls into JS to emit 'connection'.
▸ epoll-interest-updated.txtfd 18 added
┌────────────────────────────────────────────────────┐
│ epoll interest list (after)                        │
│   fd 17 (LISTEN)        EPOLLIN                    │
│   fd 18 (ESTABLISHED)   EPOLLIN  ◀── new           │
│   fd N  (eventfd)                                  │
└────────────────────────────────────────────────────┘
!
Lock this in
Every accepted connection gets its own fd, its own kernel socket, its own libuv 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

▸ connection-emit-flow.txtC++ → V8 → JS
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 handler

Object relationships after 'connection'

▸ heap-after-connection.txttwo TCPWraps now
┌────────────────────────────────────────────────────┐
│ 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

▸ stack-during-connection.txtJS frames now on top
┌────────────────────────────────────┐
│ 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:

▸ rx-pipeline.txtWIRE → KERNEL
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 wq

Kernel state after data lands

▸ recv-buffer-state.txtfd 18
┌──────────────────────────────────────────┐
│ struct sock (fd 18)                      │
│   state: ESTABLISHED                     │
│   recv buffer: [ "hello\n" ]   ◀── new   │
│   send buffer: empty                     │
└──────────────────────────────────────────┘

Main JS thread wakes from epoll_wait

▸ read-loop.txtStreamWrap drains
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

▸ read-syscall-nonblocking.txtkernel side
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).

▸ data-emit-flow.txtback into JS
ConnectionWrap/StreamWrap (C++)
   │
   ▼
V8: socket.emit('data', buffer)
   │
   ▼
your dataHandler(chunk)
   │
   ▼
socket.write("you sent: " + chunk)
!
Lock this in
The 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:

▸ write-pipeline.txtJS → wire
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

▸ backpressure-state.txtsend buffer full
┌──────────────────────────────────────────┐
│ 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                 │
└──────────────────────────────────────────┘
!
Lock this in
Backpressure isn't magic. It is the kernel's send buffer filling up, 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

▸ close-flow.txtFIN → 'end' → 'close'
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 JS

TCP state walk on our side

▸ tcp-state-walk.txtafter recv FIN
┌──────────┐  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.

▸ critical-diagram.txtmain thread vs kernel
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

StepThread / Owner
net.createServer(listener)Main JS · V8
Build Server JS objectMain JS · V8
server.listen(3000) JS layerMain JS
Build TCPWrap / uv_tcp_tMain JS · C++
socket() syscallMain JS → kernel
bind() syscallMain JS → kernel
listen() syscallMain JS → kernel
epoll_ctl(ADD, fd17)Main JS → kernel
Top-level JS finishesMain JS
Event loop enters poll phaseMain JS
epoll_wait (sleeping)Main JS BLOCKED
TCP handshake for incoming SYNKernel softirq + NIC IRQ
Connection placed on accept queueKernel
fd 17 marked EPOLLIN-readyKernel
Main thread wakes from epoll_waitMain JS · RUNNABLE
accept(17) → fd 18Main JS → kernel
Build TCPWrap for fd 18Main JS · C++
epoll_ctl(ADD, fd18)Main JS → kernel
Build net.Socket JS objectMain JS · V8
server.emit('connection', socket)Main JS · V8
Your connectionListener runsMain JS · V8
socket.on('data', ...)Main JS · V8
Bytes arriving at NICNIC + softirq
Bytes copied to fd 18 recv bufferKernel
fd 18 EPOLLIN-readyKernel
Main thread wakes againMain JS
read(18, ...) (non-blocking)Main JS → kernel
Build JS BufferMain JS · V8
socket.emit('data', chunk)Main JS · V8
Your data handler runsMain JS · V8
socket.write(...)Main JS → send buf
Bytes leaving on NICKernel + NIC TX
FIN handling, close(18)Main JS → kernel
'close' emitMain JS · V8

Final ownership map (the Part 8 analogue of Part 5's §33 box)

▸ ownership-final.txteverything in one 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

Wrong
net.createServer spawns a thread per connection.
It does not. There is one main JS thread. Every connection is a non-blocking fd registered with one shared epoll/kqueue/IOCP, and one thread services them all by reacting to readiness events.
Wrong
TCP I/O uses the libuv worker pool.
It does not. Plain TCP 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.
Wrong
The 'connection' event fires when a SYN arrives.
It fires after the kernel has fully completed the 3-way handshake and the main JS thread has subsequently woken from epoll_wait, drained the accept queue, allocated a new fd, built a net.Socket, and emitted the event.
Wrong
socket.on('data', cb) means cb fires for every TCP segment.
TCP is a byte stream. Segments are an implementation detail. Node fires '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.
Wrong
The main JS thread spins and polls sockets in a loop.
No. The main JS thread sleeps in 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.
Wrong
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

▸ correct-model.txtfive layers
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 socket
!
Lock this in
A Node TCP server is, at the OS level, one thread sleeping in epoll_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:

▸ cpu-bound-timeline.txt5-second sync loop
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.

!
Lock this in
"Non-blocking I/O" doesn't make CPU-bound JS faster. It just means slow networks don't freeze the server. CPU-bound work still blocks every connection, because every connection ultimately funnels through the same main JS thread.

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

▸ reception-analogy.txtthree actors · click each
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

▸ final-map.txtevery layer in one column
[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

!
Lock this in · 1
A 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.
!
Lock this in · 2
No libuv worker thread is involved in plain TCP I/O. The contrast with 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.
!
Lock this in · 3
The kernel completes TCP handshakes by itself. By the time JavaScript hears '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.
!
Lock this in · 4
'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.
!
Lock this in · 5
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.
The full invariant
  1. JavaScript creates the server and registers handlers.
  2. The main JS thread issues socket/bind/listen/epoll_ctl syscalls.
  3. The kernel owns all TCP state and completes handshakes alone.
  4. The main JS thread sleeps in epoll_wait until the kernel signals readiness.
  5. libuv drains ready fds with non-blocking syscalls.
  6. 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.

End of Mission 8 → Mission 9: Browser HTTP request → Node server response (coming next)
Back to JIT-Atlas