§ 0 · The Short Answer FirstOne thread, sleeping in epoll_wait — with an HTTP parser bolted on
A Node HTTP server does not use the libuv worker pool to wait for network requests. Network I/O in Node uses an entirely different mechanism: the OS readiness / completion API.
const http = require("http"); const server = http.createServer((req, res) => { res.writeHead(200, { "Content-Type": "text/plain" }); res.end("Hello, World!"); }); server.listen(3000);
A browser navigates to http://localhost:3000/. We will trace the full path:
Browser machine
↓
Browser's network stack
↓
Operating system network stack
↓
Network (or loopback)
↓
Server machine
↓
OS kernel network stack
↓
Listening socket
↓
Accepted connection socket
↓
libuv readiness watcher
↓
Node C++ TCP wrapper
↓
Node HTTP parser (llhttp)
↓
IncomingMessage + ServerResponse JS objects
↓
Your JS request handler runs on the main JS thread
↓
res.end("Hello, World!")
↓
Bytes written to kernel send buffer
↓
TCP transmission back to browser
↓
Browser parses HTTP response and rendersThe model
register interest in a socket ↓ do not block ↓ OS notifies you when the socket is readable / writable / has a new connection ↓ libuv event loop wakes up ↓ Node reads the bytes ↓ Node parses HTTP ↓ your JS handler runs
fs.readFile() uses the libuv worker pool because filesystem syscalls can block. A Node HTTP server uses the OS readiness API (epoll / kqueue / IOCP) because the kernel already knows how to multiplex thousands of sockets without blocking — it can simply tell you when a socket has bytes ready.The main JS thread never sits and waits on a socket. The OS does the waiting.
§ 1 · The Core ProblemTwo processes, two memories, one wire
A browser is on one machine. A Node server is on another machine (or the same machine via loopback). These two processes do not share memory. They do not share file descriptors. They do not share threads.
They communicate only by sending bytes through a network. So the problem decomposes into nine sub-problems:
- How do bytes leave the browser?
- How do bytes traverse the OS network stack?
- How do bytes arrive at the server's OS?
- How does Node know bytes have arrived?
- How are HTTP-formatted bytes parsed into
req/resobjects? - How does Node invoke your JS handler?
- How does your JS write a response?
- How do those response bytes go back to the browser?
- How does the browser interpret them?
Every layer adds a transformation.
Boxed problem layering
┌────────────────────────────────────────────┐
│ Browser application │
│ │
│ "fetch http://localhost:3000/" │
└─────────────────────┬──────────────────────┘
│ wants to send a request
▼
┌────────────────────────────────────────────┐
│ Browser HTTP/networking implementation │
│ │
│ - format HTTP request bytes │
│ - decide which connection to use │
└─────────────────────┬──────────────────────┘
│ uses TCP
▼
┌────────────────────────────────────────────┐
│ OS network stack (browser machine) │
│ │
│ - TCP connection state │
│ - send / receive buffers │
│ - IP routing │
└─────────────────────┬──────────────────────┘
│ bytes on the wire / loopback
▼
┌────────────────────────────────────────────┐
│ OS network stack (server machine) │
│ │
│ - listening socket │
│ - accept queue │
│ - new connection socket │
│ - receive buffer │
└─────────────────────┬──────────────────────┘
│ readiness notification
▼
┌────────────────────────────────────────────┐
│ libuv event loop │
│ │
│ - watches sockets via epoll/kqueue/IOCP │
│ - reads bytes when readable │
└─────────────────────┬──────────────────────┘
│
▼
┌────────────────────────────────────────────┐
│ Node native HTTP layer │
│ │
│ - HTTP parser (llhttp) │
│ - IncomingMessage / ServerResponse │
└─────────────────────┬──────────────────────┘
│
▼
┌────────────────────────────────────────────┐
│ JavaScript request handler │
│ │
│ (req, res) => { res.end("...") } │
└────────────────────────────────────────────┘§ 2 · The Full Ownership ChainJavaScript at the top, the kernel doing the real work
Memorize the layers. The "socket" in your JavaScript program is the top of the stack. The actual TCP connection state is owned and driven by the OS kernel. Node's role is to ask the kernel "are there bytes yet?" via libuv's readiness watcher, then read them when the kernel says yes.
Arrow flow (top to bottom)
JavaScript API ↓ Node JS http module ↓ Node JS net module ↓ Node C++ TCP wrapper ↓ libuv TCP handle ↓ OS readiness API (epoll / kqueue / IOCP) ↓ OS kernel network stack ↓ Network interface / loopback ↓ (other machine's stack) ↓ completion path back upward ↓ Node HTTP parser (llhttp) ↓ IncomingMessage / ServerResponse objects ↓ JS request handler runs on the main JS thread
Boxed ownership stack
┌────────────────────────────────────────────┐
│ JavaScript API │
│ │
│ http.createServer((req, res) => {...}) │
│ server.listen(3000) │
└─────────────────────┬──────────────────────┘
▼
┌────────────────────────────────────────────┐
│ Node JS http module │
│ │
│ - Server class │
│ - IncomingMessage class │
│ - ServerResponse class │
│ - HTTP parser glue │
└─────────────────────┬──────────────────────┘
▼
┌────────────────────────────────────────────┐
│ Node JS net module │
│ │
│ - net.Server │
│ - net.Socket │
│ - listening / connection bookkeeping │
└─────────────────────┬──────────────────────┘
▼
┌────────────────────────────────────────────┐
│ Node C++ binding layer │
│ │
│ - TCPWrap (listening) │
│ - TCPWrap (per connection) │
│ - StreamWrap read/write hooks │
│ - HTTPParser binding (llhttp) │
└─────────────────────┬──────────────────────┘
▼
┌────────────────────────────────────────────┐
│ libuv │
│ │
│ - uv_tcp_t handle │
│ - uv_listen / uv_accept │
│ - uv_read_start │
│ - event loop poll phase │
│ - epoll/kqueue/IOCP integration │
└─────────────────────┬──────────────────────┘
▼
┌────────────────────────────────────────────┐
│ OS kernel network stack │
│ │
│ - listening socket state │
│ - SYN queue / accept queue │
│ - per-connection TCP state machine │
│ - send / receive buffers │
│ - IP layer │
│ - device driver │
└─────────────────────┬──────────────────────┘
▼
┌────────────────────────────────────────────┐
│ Network interface / loopback │
│ │
│ - NIC hardware │
│ - or in-memory loopback path │
└────────────────────────────────────────────┘§ 3 · Start From CPU RealityThe CPU never understood "the internet"
The CPU does not understand HTTP, TCP, IP, sockets, request handlers, or req and res objects. The CPU only executes machine instructions.
fetch instruction ↓ decode instruction ↓ execute instruction ↓ move to next instruction ↓ repeat
Networking is built up by layers of code (and sometimes hardware) that, ultimately, just shuffle bytes between memory regions and notify each other when something has happened.
┌────────────────────────────────────────────┐ │ At the bottom │ │ │ │ - bytes sitting in RAM │ │ - copied to NIC by DMA │ │ - serialized as electrical / optical / │ │ radio signals │ │ - received by a NIC on the other side │ │ - DMA'd back into RAM │ │ - lifted up through layers of software │ └────────────────────────────────────────────┘
For loopback (127.0.0.1 or localhost), the bytes never leave the machine — they go through an in-memory path provided by the kernel.
§ 4 · Process vs ThreadNetworking lens — two processes, one kernel
When you run node server.js, the OS creates a Node process. When you open a browser, the OS has one or more browser processes. These two processes are completely separate.
Two-process view
┌────────────────────────────────┐ ┌────────────────────────────────┐
│ Browser Process │ │ Node Process │
│ │ │ │
│ - own memory │ │ - own memory │
│ - own fd table │ │ - own fd table │
│ - own threads │ │ - own threads │
│ - own sockets │ │ - own sockets │
│ │ │ │
│ Cannot directly read Node's │ │ Cannot directly read │
│ memory │ │ browser's memory │
└──────────────┬─────────────────┘ └──────────────┬─────────────────┘
│ │
└──────────── OS kernel ──────────────┘
│
▼
┌────────────────────┐
│ Network stack │
│ │
│ Mediates ALL │
│ communication │
│ between processes │
└────────────────────┘§ 5 · The Two SidesWhat each process owns · what the kernel owns
Browser side
┌────────────────────────────────────────────┐ │ Browser Process │ │ │ │ - URL parser │ │ - DNS resolver │ │ - HTTP/1.1, HTTP/2, HTTP/3 implementation │ │ - TLS layer (for https) │ │ - connection pool │ │ - cookie jar │ │ - cache │ │ - rendering engine │ │ - JS engine │ └────────────────────────────────────────────┘
Server (Node) side
┌────────────────────────────────────────────┐ │ Node Process │ │ │ │ Main JS Thread: │ │ - V8 │ │ - http module │ │ - net module │ │ - your request handler │ │ │ │ libuv: │ │ - event loop │ │ - TCP handles │ │ - epoll/kqueue/IOCP integration │ │ │ │ Worker pool: │ │ - NOT involved in network I/O │ └────────────────────────────────────────────┘
OS kernel (on each side)
┌────────────────────────────────────────────┐ │ Kernel │ │ │ │ - sockets │ │ - TCP state machine │ │ - send / receive buffers │ │ - listening socket / accept queue │ │ - port allocation │ │ - IP routing │ │ - device drivers │ └────────────────────────────────────────────┘
§ 6 · What Is a Socket?A small integer pointing at kernel state
A socket is, like a file descriptor, a process-local handle to a kernel-managed resource. For a TCP socket, that resource is a TCP endpoint: an IP address, a port, and a state machine.
fd = socket(AF_INET, SOCK_STREAM, 0);
Socket relationship
┌──────────────────────────────────────────┐
│ Process-local fd table │
│ │
│ fd 21 │
└─────────────────────┬────────────────────┘
│ points to
▼
┌──────────────────────────────────────────┐
│ Kernel "open file" entry (for socket) │
│ │
│ - type: SOCK_STREAM (TCP) │
│ - flags │
│ - reference to socket structure │
└─────────────────────┬────────────────────┘
│ refers to
▼
┌──────────────────────────────────────────┐
│ Kernel TCP socket structure │
│ │
│ - local address (IP, port) │
│ - remote address (IP, port) (if connected)│
│ - state (LISTEN, ESTABLISHED, etc.) │
│ - send buffer │
│ - receive buffer │
│ - sequence numbers │
│ - timers │
└──────────────────────────────────────────┘A socket fd is just an integer in your process. The actual TCP machinery lives in the kernel.
§ 7 · Listening Socket vs Connection SocketOne parent · many children
This is one of the most misunderstood ideas in network programming. A server has two kinds of sockets:
The listening socket
- one socket
- bound to a port (e.g. 3000)
- in LISTEN state
- does NOT carry data
- accepts incoming connections
The connection sockets
- one PER active client connection
- bound to
(server IP : 3000)on local side - bound to
(client IP : random port)on remote side - in ESTABLISHED state
- carries data for that ONE client
Server fd table
┌────────────────────────────────────────────┐ │ Server Node Process │ │ │ │ fd 7 ── Listening socket │ │ local: 0.0.0.0:3000 │ │ state: LISTEN │ │ │ │ fd 12 ── Connection from browser A │ │ local: 127.0.0.1:3000 │ │ remote: 127.0.0.1:54321 │ │ state: ESTABLISHED │ │ │ │ fd 15 ── Connection from browser B │ │ local: 127.0.0.1:3000 │ │ remote: 127.0.0.1:54322 │ │ state: ESTABLISHED │ │ │ │ fd 19 ── Connection from another client │ │ local: 127.0.0.1:3000 │ │ remote: 192.168.1.5:60001 │ │ state: ESTABLISHED │ └────────────────────────────────────────────┘
How a connection socket comes into existence
client tries to connect ↓ TCP handshake reaches kernel ↓ kernel queues connection on listening socket's accept queue ↓ server calls accept() ↓ kernel removes connection from accept queue ↓ kernel creates a NEW socket fd for that connection ↓ server now has a per-connection socket
server.listen(3000) produces one listening socket. Every browser request produces an additional connection socket. The listening socket itself never carries HTTP data — it only produces children.§ 8 · The OS Network StackApplication · transport · network · link
When the browser sends bytes, here is the layered processing:
HTTP request bytes ↓ TCP layer — adds sequence numbers, ports, checksum ↓ IP layer — adds source/destination IP, routing decision ↓ Link layer — adds MAC addresses (ethernet/wifi) or skipped on loopback ↓ Device driver — hands frame to NIC (or loopback path) ↓ Wire / RF / fiber / loopback memory
On the receiving side, the layers run in reverse.
Boxed network stack
┌──────────────────────────────────────────┐
│ Application layer │
│ │
│ - HTTP request bytes │
│ - "GET / HTTP/1.1\r\nHost: ..." │
└─────────────────────┬────────────────────┘
│ written into socket
▼
┌──────────────────────────────────────────┐
│ Transport layer (TCP) │
│ │
│ - segments bytes into TCP segments │
│ - manages sequence numbers │
│ - manages retransmission │
│ - flow control / congestion control │
└─────────────────────┬────────────────────┘
│
▼
┌──────────────────────────────────────────┐
│ Network layer (IP) │
│ │
│ - wraps segments in IP packets │
│ - chooses route │
│ - handles fragmentation if needed │
└─────────────────────┬────────────────────┘
│
▼
┌──────────────────────────────────────────┐
│ Link layer │
│ │
│ - frames packets │
│ - addresses by MAC │
│ - or uses loopback path in-memory │
└─────────────────────┬────────────────────┘
▼
┌──────────────────────────────────────────┐
│ Hardware / loopback │
│ │
│ - NIC │
│ - or kernel loopback driver │
└──────────────────────────────────────────┘For our example (localhost:3000), the stack is shorter in practice — bytes pass through the loopback driver instead of leaving the machine — but the logical layering is identical.
§ 9 · The Browser SideFrom URL to bytes on the wire
We will not deeply trace the browser's internals (Node is the focus), but we need a clear mental model. When the browser hits http://localhost:3000/:
- Browser parses URL
- Browser checks connection pool — any open conn to
localhost:3000? - If not, browser performs DNS lookup — "localhost" → 127.0.0.1
- Browser asks kernel for a TCP socket
- Browser kernel performs TCP three-way handshake with server
- Browser formats HTTP request bytes
- Browser writes bytes into kernel's send buffer
- Kernel transmits bytes
Arrow flow — browser process and its kernel
BROWSER PROCESS BROWSER OS KERNEL
fetch("http://localhost:3000/")
│
├── parse URL
├── DNS lookup ─────────────────▶ resolver / cache
│ ◀────────────── 127.0.0.1
│
├── socket() ──────────────────▶ create TCP socket fd
├── connect(127.0.0.1:3000) ───▶ TCP handshake begins
│ │
│ ▼
│ SYN sent to server
│ SYN-ACK received
│ ACK sent
│ │
│ ◀────────────── connected
│
├── format request bytes
├── write(fd, bytes, len) ─────▶ bytes copied into send buffer
│ │
│ ▼
│ transmitted via loopback / NIC§ 10 · The TCP Three-Way HandshakeTwo kernels alone — Node hears nothing yet
Before a single HTTP byte travels, TCP must establish a connection.
Step by step
BROWSER (CLIENT) SERVER
socket()
connect(IP, 3000)
│
├── send SYN ───────────────────▶ kernel receives SYN
│ │
│ ├── if listening socket exists on port 3000:
│ │ add half-open conn to SYN queue
│ │
│ ◀───────────── send SYN-ACK
│
├── send ACK ───────────────────▶ kernel marks connection complete
│ │
│ └── moves connection to accept queue
│
connect() returnsServer-side queues during handshake
┌──────────────────────────────────────────┐ │ Server kernel — listening socket fd 7 │ │ │ │ ┌──────────────────────────────────────┐ │ │ │ SYN queue (incomplete handshakes) │ │ │ │ │ │ │ │ - half-open conn from 127.0.0.1:54321│ │ │ └──────────────────────────────────────┘ │ │ │ │ ┌──────────────────────────────────────┐ │ │ │ Accept queue (completed handshakes) │ │ │ │ │ │ │ │ - ready conn from 127.0.0.1:54322 │ │ │ │ - ready conn from 192.168.1.5:60001 │ │ │ └──────────────────────────────────────┘ │ └──────────────────────────────────────────┘
When Node calls accept(), the kernel:
- removes one entry from the accept queue
- returns a new fd representing that established connection
§ 11 · The HTTP Request BytesJust text · sitting on top of TCP
Before transmitting, the browser builds bytes that look like this:
GET / HTTP/1.1\r\n Host: localhost:3000\r\n User-Agent: Mozilla/5.0 ...\r\n Accept: text/html,...\r\n Accept-Language: en-US\r\n Accept-Encoding: gzip, deflate\r\n Connection: keep-alive\r\n \r\n
The blank line (\r\n immediately after the last header) signals end of headers. For a GET request there is no body.
Boxed view of the request
┌────────────────────────────────────────────┐ │ HTTP request bytes (one big byte buffer) │ │ │ │ Request line: │ │ GET / HTTP/1.1\r\n │ │ │ │ Headers: │ │ Host: localhost:3000\r\n │ │ User-Agent: ...\r\n │ │ Accept: ...\r\n │ │ ... │ │ │ │ End of headers: │ │ \r\n │ │ │ │ Body (if any): │ │ (empty for GET) │ └────────────────────────────────────────────┘
§ 12 · What server.listen(3000) Actually DoesIt does not wait — it registers and returns
Now we move to the Node side. Code:
const server = http.createServer((req, res) => { /* ... */ }); server.listen(3000);
server.listen(3000) is a JavaScript call on the main JS thread. The chain:
http.Server.listen(3000) ↓ delegates to net.Server.listen(3000) ↓ Node JS net wrapper ↓ Node C++ TCPWrap binding ↓ libuv uv_tcp_t handle ↓ OS syscalls: socket() / bind() / listen() ↓ kernel creates listening socket ↓ libuv registers interest with epoll/kqueue/IOCP
Boxed call chain
┌──────────────────────────────────────────┐
│ JavaScript │
│ │
│ server.listen(3000) │
└─────────────────────┬────────────────────┘
▼
┌──────────────────────────────────────────┐
│ Node JS http module │
│ │
│ - delegates to net.Server │
└─────────────────────┬────────────────────┘
▼
┌──────────────────────────────────────────┐
│ Node JS net module │
│ │
│ - sets up listener bookkeeping │
│ - calls into C++ TCPWrap │
└─────────────────────┬────────────────────┘
▼
┌──────────────────────────────────────────┐
│ Node C++ binding (TCPWrap) │
│ │
│ - allocates a uv_tcp_t handle │
│ - calls uv_tcp_bind │
│ - calls uv_listen │
│ - registers connection callback │
└─────────────────────┬────────────────────┘
▼
┌──────────────────────────────────────────┐
│ libuv │
│ │
│ - performs socket() / bind() / listen() │
│ - registers fd with epoll/kqueue/IOCP │
│ - asks kernel: "wake me when readable │
│ on this fd" │
└─────────────────────┬────────────────────┘
▼
┌──────────────────────────────────────────┐
│ OS Kernel │
│ │
│ - allocates socket structure │
│ - assigns local address 0.0.0.0:3000 │
│ - sets state to LISTEN │
│ - prepares accept queue │
└──────────────────────────────────────────┘server.listen(3000) does not "wait for connections." It tells the kernel: "I will be the owner of port 3000. Queue incoming connections for me, and tell libuv when one is ready." Then it returns immediately.§ 13 · The bind() and listen() SyscallsWhat the kernel actually does
What does the kernel actually do when libuv issues these three syscalls?
socket(AF_INET, SOCK_STREAM, 0) ↓ returns fd 7 — a fresh TCP socket structure ↓ bind(fd, "0.0.0.0:3000") ↓ kernel checks: is port 3000 already in use? ↓ if yes: EADDRINUSE error if no: assign port to this socket ↓ listen(fd, backlog) ↓ kernel marks the socket as LISTEN ↓ kernel allocates SYN queue and accept queue
Kernel state after listen()
┌────────────────────────────────────────────┐ │ Kernel — socket fd 7 in Node process │ │ │ │ type: TCP (SOCK_STREAM) │ │ state: LISTEN │ │ local addr: 0.0.0.0:3000 │ │ remote addr: (none) │ │ │ │ SYN queue: [empty] │ │ Accept queue: [empty] │ │ │ │ Will produce: new connection sockets │ │ upon accept() │ └────────────────────────────────────────────┘
listen(), the kernel is now the active component of the server. Node does nothing. The kernel waits for SYN packets and processes handshakes. When connections complete, they accumulate in the accept queue.§ 14 · The OS Readiness API — epoll, kqueue, IOCPOne thread · thousands of sockets
This is the crucial difference from fs.readFile(). For a file read, libuv hands the work to a worker thread, which blocks in read(). For network sockets, libuv does not need a worker thread, because the OS itself provides a way to ask:
"Of these N sockets I care about, which ones are ready right now?"
This is multiplexing.
Linux: epoll
epoll_create() → creates an epoll instance epoll_ctl(fd) → register a fd with the epoll instance epoll_wait() → block until any registered fd is ready
macOS / BSD: kqueue
kqueue() → creates an event queue kevent(...) → register interest, or wait for events
Windows: IOCP
- different model: completion-based instead of readiness-based - you submit operations; the kernel completes them and notifies you
libuv abstracts these differences. From Node's perspective: "register fd with libuv → libuv will tell me when something happens."
The multiplexing model
┌────────────────────────────────────────────┐
│ Node Process — Main JS Thread / libuv │
│ │
│ Registered sockets: │
│ fd 7 (listening) │
│ fd 12 (connection A) │
│ fd 15 (connection B) │
│ fd 19 (connection C) │
│ │
│ ┌──────────────────────────────────────┐ │
│ │ epoll_wait() │ │
│ │ │ │
│ │ Blocks until ANY of those fds │ │
│ │ becomes ready. │ │
│ └──────────────────────────────────────┘ │
└─────────────────────┬──────────────────────┘
│
▼
┌────────────────────────────────────────────┐
│ Kernel │
│ │
│ - tracks readiness of every registered fd │
│ - when any becomes readable, marks ready │
│ - returns to epoll_wait caller │
└────────────────────────────────────────────┘§ 15 · The libuv Event Loop — Poll PhaseWhere Node sleeps when there's nothing to do
The libuv event loop has phases. The phase that matters here is the poll phase.
loop iteration: 1. timers 2. pending callbacks 3. idle / prepare 4. poll ◄── epoll_wait / kevent / IOCP wait happens here 5. check (setImmediate) 6. close callbacks
Arrow flow inside one iteration
event loop iteration begins
↓
run any due timers
↓
run pending callbacks
↓
enter POLL phase
↓
if there are watched I/O handles:
call epoll_wait / kevent / IOCP
↓
block until at least one fd is ready
OR timeout (next timer due)
↓
for each ready fd:
run the libuv I/O callback
which runs the Node C++ callback
which (eventually) runs JS
↓
run check callbacks (setImmediate)
↓
run close callbacks
↓
next iterationBoxed loop view
┌──────────────────────────────────────────┐
│ libuv event loop (main JS thread) │
│ │
│ while (alive) { │
│ run_timers(); │
│ run_pending(); │
│ run_idle_prepare(); │
│ │
│ poll_for_io(); // ◄── waits here │
│ │
│ run_check(); │
│ run_close(); │
│ } │
└──────────────────────────────────────────┘epoll_wait (or equivalent), sleeping. It is not spinning. It is not polling. It is genuinely asleep, costing the CPU nothing. The kernel wakes it the instant any registered fd becomes ready.§ 16 · Connection Arrives — What the Kernel DoesHandshake without JS involvement
The browser sends a SYN.
Wire / loopback ─── SYN ──▶ server NIC / loopback driver ↓ kernel network stack receives SYN ↓ kernel finds that fd 7 (LISTEN on port 3000) matches ↓ kernel allocates half-open connection state ↓ kernel sends SYN-ACK ↓ browser sends ACK ↓ kernel completes handshake ↓ kernel moves connection from SYN queue to accept queue ↓ kernel marks fd 7 as "readable" ↓ (because there's now an acceptable connection on it) ↓ kernel signals epoll/kqueue/IOCP that fd 7 is ready
Accept queue state
Before connection arrives: ┌──────────────────────────────────────────┐ │ Listening fd 7 │ │ state: LISTEN │ │ │ │ accept queue: [empty] │ │ │ │ Ready signal? No │ └──────────────────────────────────────────┘ After handshake completes: ┌──────────────────────────────────────────┐ │ Listening fd 7 │ │ state: LISTEN │ │ │ │ accept queue: │ │ [conn A: 127.0.0.1:54321] │ │ │ │ Ready signal? YES │ └──────────────────────────────────────────┘
The kernel notifies epoll. The main JS thread, sleeping in epoll_wait, wakes up.
§ 17 · libuv Reacts — Calls accept()From kernel signal to 'connection' event
The main JS thread is now back in user space, inside libuv's poll phase.
Step by step
epoll_wait returns ↓ "fd 7 is readable" ↓ libuv looks up handle for fd 7 ↓ calls registered I/O callback for that handle ↓ that callback is "on_connection" ↓ on_connection calls accept(fd 7) ↓ kernel removes one entry from accept queue ↓ kernel allocates a NEW socket structure ↓ returns new fd (e.g. fd 12) to libuv ↓ libuv creates a uv_tcp_t handle for fd 12 ↓ libuv invokes Node C++ connection callback ↓ Node C++ creates a TCPWrap object for fd 12 ↓ Node C++ calls back into JS: net.Server emits 'connection' ↓ JS http server's 'connection' listener runs ↓ http server attaches an HTTP parser to the new socket ↓ http server tells libuv: "start reading on fd 12"
Socket relationship after accept
┌────────────────────────────────────────────┐ │ Node Process │ │ │ │ fd 7 ── Listening socket (still LISTEN) │ │ │ │ fd 12 ── New connection socket │ │ state: ESTABLISHED │ │ remote: 127.0.0.1:54321 │ │ │ │ Wrapped by: │ │ libuv uv_tcp_t │ │ Wrapped by: │ │ Node C++ TCPWrap │ │ Wrapped by: │ │ JS net.Socket │ │ With: │ │ HTTP parser attached │ └────────────────────────────────────────────┘
accept() does not block here. The kernel already had a complete connection sitting in the accept queue. accept() simply hands it to Node and returns a new fd.§ 18 · The Wrapper Layers Around a ConnectionFive names · one socket
Once accepted, a single TCP connection is represented at multiple layers:
Wrapper stack for ONE connection
┌────────────────────────────────────────────┐
│ JavaScript │
│ │
│ IncomingMessage (req) ◄── stream │
│ ServerResponse (res) ◄── stream │
│ parser state (llhttp) │
└─────────────────────┬──────────────────────┘
│ both wrap the same socket
▼
┌────────────────────────────────────────────┐
│ JavaScript │
│ │
│ net.Socket (a Duplex stream) │
└─────────────────────┬──────────────────────┘
▼
┌────────────────────────────────────────────┐
│ Node C++ │
│ │
│ TCPWrap │
│ - read callbacks │
│ - write requests │
│ - links JS object to libuv handle │
└─────────────────────┬──────────────────────┘
▼
┌────────────────────────────────────────────┐
│ libuv │
│ │
│ uv_tcp_t handle │
│ - holds fd │
│ - registered with epoll/kqueue/IOCP │
└─────────────────────┬──────────────────────┘
▼
┌────────────────────────────────────────────┐
│ Kernel │
│ │
│ socket structure (fd 12) │
│ - TCP state machine │
│ - send buffer │
│ - receive buffer │
└────────────────────────────────────────────┘req and res in your handler are JavaScript streams that ultimately read from and write to the same kernel TCP socket. The HTTP parser sits between the raw byte stream and the JS-level req object.§ 19 · uv_read_start — Bytes Will Trigger Callbacks"Watch fd 12 for readability"
Right after the connection is set up, Node tells libuv: "Watch fd 12 for readability. When bytes arrive, give them to me." This is uv_read_start.
Internally
uv_read_start(handle) ↓ libuv updates epoll/kqueue/IOCP registration ↓ "now also notify on readable for fd 12"
Registration state after uv_read_start
┌──────────────────────────────────────────┐ │ epoll registration │ │ │ │ fd 7 → readable? notify (accept queue) │ │ fd 12 → readable? notify (data arrived) │ │ fd 12 → writable? notify (when needed) │ └──────────────────────────────────────────┘
§ 20 · The Browser's HTTP Request Bytes ArriveReceive buffer fills · epoll fires
Now the browser sends:
GET / HTTP/1.1\r\n Host: localhost:3000\r\n ...\r\n \r\n
Arrow flow — kernel-to-kernel
BROWSER KERNEL SERVER KERNEL
bytes leave send buffer
│
├── travel via loopback
│ or NIC → wire → NIC
│
▼
bytes arrive at receive path
│
├── TCP layer reorders if needed
├── delivers to socket fd 12 receive buffer
│
├── marks fd 12 as readable
│
└── notifies epollKernel buffer state
┌──────────────────────────────────────────┐ │ Kernel — socket fd 12 │ │ │ │ Receive buffer: │ │ ┌────────────────────────────────┐ │ │ │ "GET / HTTP/1.1\r\n" │ │ │ │ "Host: localhost:3000\r\n" │ │ │ │ "User-Agent: ...\r\n" │ │ │ │ "Accept: ...\r\n" │ │ │ │ "\r\n" │ │ │ └────────────────────────────────┘ │ │ │ │ State: ESTABLISHED │ │ Readable? YES │ └──────────────────────────────────────────┘
The kernel notifies epoll. The main JS thread, sleeping in epoll_wait, wakes again.
§ 21 · libuv Reads the BytesNon-blocking read · hand-off to llhttp
Step by step
epoll_wait returns ↓ "fd 12 is readable" ↓ libuv looks up uv_tcp_t for fd 12 ↓ calls read() syscall ↓ kernel copies bytes from receive buffer into a Node-supplied buffer ↓ read() returns: number of bytes read ↓ libuv invokes the read callback (alloc_cb / read_cb) ↓ Node C++ TCPWrap receives the bytes ↓ Node C++ feeds bytes to the HTTP parser
Buffer hand-off
┌──────────────────────────────────────────┐
│ Kernel receive buffer │
│ "GET / HTTP/1.1\r\nHost: ..." │
└─────────────────────┬────────────────────┘
│ read(fd, dst, n)
▼
┌──────────────────────────────────────────┐
│ Node-allocated Buffer (in process memory)│
│ "GET / HTTP/1.1\r\nHost: ..." │
└─────────────────────┬────────────────────┘
│ delivered to TCPWrap
▼
┌──────────────────────────────────────────┐
│ Node C++ HTTP parser binding (llhttp) │
└──────────────────────────────────────────┘read() here is not blocking. The kernel was already told that bytes are available, so the call returns immediately with whatever is currently in the receive buffer. If only some of the request has arrived (TCP can deliver in chunks), Node will see read() callbacks multiple times.§ 22 · The HTTP Parser — llhttpA C state machine that lives inside Node
Modern Node uses llhttp, a parser generated from a state-machine description. It is a C library, accessed via a Node C++ binding. It is incremental:
- you feed it a chunk of bytes
- it advances its state machine as far as it can
- it calls callbacks for each parsed piece:
on_message_begin,on_url,on_header_field,on_header_value,on_headers_complete,on_body,on_message_complete
Parser state mid-request
┌────────────────────────────────────────────┐
│ HTTP Parser (llhttp) for fd 12 │
│ │
│ current state: PARSING_HEADERS │
│ parsed so far: │
│ method: GET │
│ url: "/" │
│ version: 1.1 │
│ headers: { Host: "localhost:3000", │
│ ... } │
│ body so far: (none yet) │
└────────────────────────────────────────────┘Arrow flow as bytes feed in
chunk arrives ↓ parser.execute(chunk) ↓ state machine advances byte-by-byte ↓ when it sees "GET ": on_url callback prepared ↓ when it sees " HTTP/1.1\r\n": request line complete ↓ when it sees a header: on_header_field, on_header_value ↓ when it sees the blank line: on_headers_complete ◄── critical!
on_headers_complete is the moment Node decides:
"We have enough to build the JS req/res objects and call the user's request handler."
req stream as they come.§ 23 · IncomingMessage and ServerResponseTwo streams · one socket
When on_headers_complete fires, Node creates two objects:
req = new IncomingMessage(socket); res = new ServerResponse(req);
req is a Readable stream wrapping the inbound side of the socket. res is a Writable stream wrapping the outbound side.
Object relationship
┌──────────────────────────────────────────┐
│ JavaScript objects for this request │
│ │
│ req (IncomingMessage) │
│ - method: "GET" │
│ - url: "/" │
│ - httpVersion: "1.1" │
│ - headers: { host: ..., ... } │
│ - readable stream of body bytes │
│ - .socket → net.Socket │
│ │
│ res (ServerResponse) │
│ - statusCode: 200 (default) │
│ - headers (not yet sent) │
│ - writable stream of body bytes │
│ - .socket → same net.Socket │
│ │
│ │ │
│ ▼ │
│ ┌────────────────────┐ │
│ │ net.Socket (fd 12) │ │
│ └────────────────────┘ │
└──────────────────────────────────────────┘Arrow flow into JS
on_headers_complete ↓ Node C++ asks JS: "construct req/res for this connection" ↓ http module's parser-onRequest hook runs (JS) ↓ new IncomingMessage(socket) created ↓ new ServerResponse(req) created ↓ server emits 'request' event with (req, res) ↓ your handler runs
req and res are not real connections. They are JS stream wrappers around the same underlying socket, configured to behave as the read side and write side of a single HTTP exchange.§ 24 · Your Request Handler Runs on the Main JS ThreadThe moment all that machinery converges
The moment that took us through the kernel, libuv, the HTTP parser, and the http module is the moment your code runs:
(req, res) => { res.writeHead(200, { "Content-Type": "text/plain" }); res.end("Hello, World!"); }
Call stack at this instant
┌──────────────────────────────────┐
│ your handler (req, res) => {...} │
├──────────────────────────────────┤
│ http server emit('request') │
├──────────────────────────────────┤
│ http parser onHeadersComplete │
├──────────────────────────────────┤
│ TCPWrap onread callback │
├──────────────────────────────────┤
│ libuv I/O callback dispatch │
└──────────────────────────────────┘This entire stack is on the main JS thread. The same thread that called server.listen(3000) minutes (or microseconds) ago. The same thread that runs your modules' top-level code. The same thread that runs every other JS callback.
§ 25 · res.writeHead — Building the Status Line and HeadersConfigures · doesn't transmit (yet)
res.writeHead(200, { "Content-Type": "text/plain" }) does not transmit anything yet (necessarily). It records the status code and headers in the res object. Internally, Node prepares a buffer like:
HTTP/1.1 200 OK\r\n Content-Type: text/plain\r\n Date: ...\r\n Connection: keep-alive\r\n Content-Length: 13\r\n \r\n
(Some of those headers are added automatically.)
Boxed res state after writeHead
┌──────────────────────────────────────────┐ │ ServerResponse (res) │ │ │ │ statusCode: 200 │ │ headers: │ │ Content-Type: text/plain │ │ (Date, Connection added later) │ │ │ │ headersSent: false (not flushed yet) │ │ │ │ pending output buffer: │ │ (status line + headers, ready) │ └──────────────────────────────────────────┘
writeHead configures. Bytes don't actually leave Node until something triggers a flush — usually res.write(), res.end(), or a buffer threshold being crossed.§ 26 · res.end — Flush · Write · Mark CompleteFrom JS string to kernel send buffer
res.end("Hello, World!") does several things:
- If headers haven't been flushed yet, flush them now.
- Write the body bytes.
- Mark the response complete.
- Possibly signal the parser/connection that this exchange is done.
Step by step
res.end("Hello, World!")
↓
ensure headers flushed
↓
combine status-line + headers + body bytes
↓
hand the resulting buffer to the underlying net.Socket
↓
net.Socket calls C++ TCPWrap.write
↓
TCPWrap calls uv_write on the libuv handle
↓
libuv calls write() syscall
↓
kernel copies bytes into socket fd 12's send buffer
↓
write() returns number of bytes accepted
↓
if all accepted: completion will fire soon
if partial: libuv arranges to continue when socket writableBoxed flow into kernel send buffer
┌──────────────────────────────────────────┐
│ res.end("Hello, World!") │
└─────────────────────┬────────────────────┘
│ JS → C++
▼
┌──────────────────────────────────────────┐
│ Buffer (process memory) │
│ │
│ "HTTP/1.1 200 OK\r\n" │
│ "Content-Type: text/plain\r\n" │
│ "Content-Length: 13\r\n" │
│ "\r\n" │
│ "Hello, World!" │
└─────────────────────┬────────────────────┘
│ uv_write → write() syscall
▼
┌──────────────────────────────────────────┐
│ Kernel — socket fd 12 send buffer │
│ │
│ bytes queued for transmission │
└─────────────────────┬────────────────────┘
│ TCP layer transmits
▼
┌──────────────────────────────────────────┐
│ Loopback / NIC │
└──────────────────────────────────────────┘res.end does not wait for the browser to receive the bytes. It hands the bytes to the kernel. The kernel and TCP take care of delivery. If the socket buffer is full, libuv waits for writability and finishes the send later — without blocking your JS thread.§ 27 · Backpressure — When the Socket Is Slowlibuv waits for EPOLLOUT · JS keeps running
Suppose the response body is huge and the kernel send buffer fills up.
Step by step
write(fd, buf, n) ↓ kernel: only m bytes fit (m < n) ↓ kernel returns m ↓ libuv records: "still have (n - m) bytes pending" ↓ libuv asks epoll: "tell me when fd 12 is WRITABLE" ↓ main JS thread continues with other work ↓ later: kernel makes room ↓ epoll signals fd 12 writable ↓ libuv resumes write ↓ when all done, calls write_cb ↓ res emits 'drain' event in JS
Pending write state
┌──────────────────────────────────────────┐ │ Pending write request (libuv) │ │ │ │ fd: 12 │ │ remaining bytes: 32k of 64k │ │ on completion: invoke write_cb │ │ │ │ epoll watching: WRITABLE │ └──────────────────────────────────────────┘
epoll/kqueue/IOCP) to be notified when more bytes can be written. This is exactly why streams in Node have .write() returning a boolean and emit 'drain' — to expose this OS-level reality to your code.§ 28 · The Bytes Flow Back to the BrowserReverse path · symmetric layering
On the browser's machine, the reverse of the inbound path happens.
Step by step
SERVER KERNEL BROWSER KERNEL
bytes leave fd 12 send buffer
│
├── TCP transmission
│
▼
bytes arrive
│
├── delivered to browser's connection socket
├── receive buffer fills
├── browser's I/O multiplexer notified
│
└── browser code reads themBrowser's HTTP implementation parses them, and the page or fetch() resolves.
Full round trip
┌─────────────────────────┐ ┌─────────────────────────┐
│ Browser process │ │ Node process │
│ │ │ │
│ JS: │ │ JS: │
│ fetch("...3000") │ │ handler(req, res) │
│ │ │ │ ▲ │
│ ▼ │ │ │ │
│ HTTP impl │ │ http module / parser │
│ │ │ │ ▲ │
│ ▼ │ │ │ │
│ TCP socket fd │ │ net.Socket fd 12 │
└────────┬────────────────┘ └────────┬────────────────┘
│ │
▼ ▼
┌─────────────────────────┐ ┌─────────────────────────┐
│ Browser kernel │ │ Server kernel │
│ send buffer ──────────┼─── request ──▶│ recv buffer │
│ recv buffer ◀─────────┼─── response ──│ send buffer │
└─────────────────────────┘ └─────────────────────────┘
│ │
└────────── network / loopback ───────────┘§ 29 · Connection Lifecycle After the Responsekeep-alive · close · parser reset
After res.end(), what happens to the TCP connection? It depends on:
- HTTP version
Connectionheader- keep-alive settings
For HTTP/1.1 with Connection: keep-alive (the default)
- Connection stays open - Browser may send another request on the same fd - Server's HTTP parser is reset to wait for the next request - libuv keeps watching fd 12
For Connection: close
- Server closes the socket after response - close() syscall - TCP FIN exchanged - fd is freed
Parser reset for keep-alive
┌──────────────────────────────────────────┐ │ HTTP Parser for fd 12 (after response) │ │ │ │ state: WAITING_FOR_NEXT_REQUEST │ │ previous req/res: garbage-collectable │ │ new req/res will be made on next │ │ on_headers_complete │ └──────────────────────────────────────────┘
req/res JS objects. The socket fd, and the kernel-side TCP state, persist across them.§ 30 · The Critical DiagramEnd-to-end · browser to handler to wire and back
This is the diagram for the entire operation. Memorize it.
BROWSER PROCESS / KERNEL NETWORK / LOOPBACK SERVER KERNEL SERVER NODE PROCESS
fetch("http://localhost:3000/")
│
├── DNS lookup
├── socket()
├── connect()
│ │
│ ├── SYN ─────────────────────────────────────────────▶ kernel processes SYN
│ │ │
│ │ ◀──────────────── SYN-ACK ──────────────────────────── │
│ │ │
│ └── ACK ─────────────────────────────────────────────▶ handshake done
│ │
│ ▼
│ accept queue: +1
│ │
│ fd 7 → readable
│ │
│ epoll signals ────────────▶ libuv wakes
│ │
│ ├── accept(fd 7) → fd 12
│ ├── new TCPWrap, net.Socket
│ ├── http parser attached
│ ├── uv_read_start(fd 12)
│ │
├── format HTTP request bytes │
├── write(fd, "GET / HTTP/1.1...") ─────────────────────▶ recv buffer for fd 12 fills │
│ │ │
│ fd 12 → readable │
│ │ │
│ epoll signals ────────────▶ libuv wakes
│ │
│ ├── read(fd 12) → bytes
│ ├── feed bytes to llhttp
│ ├── on_headers_complete
│ ├── make req, res
│ │
│ ▼
│ handler(req, res) runs on main JS thread
│ │
│ ├── res.writeHead(200, ...)
│ ├── res.end("Hello, World!")
│ │ │
│ │ ▼
│ uv_write → write(fd 12, response)
│ │
│ send buffer for fd 12 fills
│ │
│ TCP transmits
│ │
│ ◀────────── HTTP/1.1 200 OK + body ─────────────────────────── │
│
▼
browser recv buffer fills
│
parses response, fetch() resolves§ 31 · Final ArchitectureTwo machines · all layers · one TCP connection
The end-to-end layered architecture in one boxed diagram. Each box is a layer; arrows show where bytes (and notifications) move.
┌────────────────────────────────────────────────────────────────────┐
│ BROWSER MACHINE │
│ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ Browser Process │ │
│ │ - URL parser · HTTP client · TCP socket fd │ │
│ └────────────────────────┬─────────────────────────────────────┘ │
│ │ │
│ ┌────────────────────────▼─────────────────────────────────────┐ │
│ │ Browser-side Kernel │ │
│ │ - TCP state machine · send / recv buffers │ │
│ └────────────────────────┬─────────────────────────────────────┘ │
└───────────────────────────┼────────────────────────────────────────┘
│ network / loopback
▼
┌────────────────────────────────────────────────────────────────────┐
│ SERVER MACHINE │
│ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ Server-side Kernel │ │
│ │ Listening socket fd 7 (LISTEN, accept queue) │ │
│ │ Connection socket fd 12 (ESTABLISHED, send/recv buffers) │ │
│ │ epoll/kqueue/IOCP watches both │ │
│ └────────────────────────┬─────────────────────────────────────┘ │
│ │ readiness notifications │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ Node Process · Main JS Thread │ │
│ │ libuv event loop (poll phase blocks in epoll_wait) │ │
│ │ Node C++ TCPWrap + HTTPParser (llhttp per connection) │ │
│ │ Node JS http.Server · net.Socket · IncomingMessage · res │ │
│ │ V8 executes your handler(req, res) │ │
│ │ │ │
│ │ libuv worker pool: NOT involved in HTTP I/O │ │
│ └──────────────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────────┘§ 32 · Step-by-Step Runtime TraceAll 45 steps · in execution order
Every step in the request lifecycle, in the order they actually happen. Each row maps to the thread or kernel component that performs it.
| Step | Thread / Owner |
|---|---|
Node process starts, runs server.js | Main JS · V8 |
http.createServer(handler) creates an http.Server instance | Main JS · V8 |
server.listen(3000) called on main JS thread | Main JS |
| JS net wrapper invokes Node C++ TCPWrap | Main JS · C++ |
| C++ allocates uv_tcp_t handle | Main JS · C++ |
libuv calls socket() → returns fd 7 | Main JS → kernel |
libuv calls bind(fd 7, "0.0.0.0:3000") | Main JS → kernel |
libuv calls listen(fd 7, backlog) | Main JS → kernel |
| libuv registers fd 7 with epoll | Main JS → kernel |
server.listen returns | Main JS |
| Main JS thread finishes top-level code | Main JS |
Event loop enters poll phase, calls epoll_wait → blocks | Main JS BLOCKED |
| Browser performs DNS lookup for "localhost" | Browser |
Browser opens TCP socket and calls connect(127.0.0.1:3000) | Browser → browser kernel |
| Kernel handshake: SYN, SYN-ACK, ACK | Both kernels |
| Server kernel completes handshake, queues connection on accept queue | Kernel |
| Server kernel marks fd 7 readable, signals epoll | Kernel |
epoll_wait returns on main JS thread | Main JS · RUNNABLE |
| libuv invokes connection I/O callback | Main JS |
Node calls accept(fd 7) → returns fd 12 | Main JS → kernel |
Node creates uv_tcp_t for fd 12, TCPWrap, net.Socket | Main JS · C++ |
http.Server attaches HTTP parser (llhttp) to that socket | Main JS · C++ |
Node calls uv_read_start(fd 12), epoll watches fd 12 readable | Main JS → kernel |
| Connection callback finishes, JS stack empties, event loop continues | Main JS |
| Browser writes HTTP request bytes into its kernel send buffer | Browser → browser kernel |
| Bytes traverse loopback / network and arrive at server kernel | Both kernels + NIC |
| Bytes deposited into fd 12's recv buffer | Kernel |
| Server kernel marks fd 12 readable, signals epoll | Kernel |
epoll_wait returns on main JS thread | Main JS |
| libuv invokes read I/O callback for fd 12 | Main JS |
Node calls read(fd 12) → bytes copied to a Node Buffer | Main JS → kernel |
| TCPWrap forwards bytes to HTTPParser | Main JS · C++ |
| llhttp state machine advances, parsing request line, headers | Main JS · C++ |
| on_headers_complete fires | Main JS · C++ |
| http module creates IncomingMessage (req) and ServerResponse (res) | Main JS · V8 |
http.Server emits 'request' event with (req, res) | Main JS · V8 |
| Your handler runs on the main JS thread | Main JS · V8 |
res.writeHead(200, ...) records headers in res object | Main JS · V8 |
res.end("Hello, World!") triggers flush | Main JS · V8 |
TCPWrap calls uv_write | Main JS · C++ |
libuv calls write(fd 12, buffer) | Main JS → kernel |
| Kernel copies bytes into fd 12 send buffer; TCP transmits | Kernel + NIC TX |
| Browser kernel receives bytes, fills its recv buffer | Browser kernel |
Browser HTTP client parses response; fetch() resolves; page renders | Browser |
| Server: depending on keep-alive, parser resets or socket closes | Main JS |
| Event loop continues iterating, watching for more events | Main JS |
§ 33 · Thread Ownership MapThree tiers · each owns specific work
The three-tier collapsed view of who owns what. Read top-down: process at the top, kernel in the middle, hardware at the bottom.
┌──────────────────────────────────────────────────────────────────────────┐
│ NODE PROCESS │
│ │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ Main JS Thread │ │
│ │ - all JS execution │ │
│ │ - http.Server / net.Server / Socket JS objects │ │
│ │ - C++ TCPWrap / StreamWrap / HTTPParser │ │
│ │ - libuv event loop (poll phase calls epoll_wait) │ │
│ │ - issues socket/bind/listen/accept/read/write syscalls │ │
│ │ - feeds bytes to llhttp · creates req/res · runs your handler │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ Libuv Worker Thread Pool │ │
│ │ * NOT used for HTTP request/response * │ │
│ │ (only relevant for: 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 │
└──────────────────────────────────────────────────────────────────────────┘§ 34 · Is the Worker Pool Involved?No · network I/O uses readiness, not worker threads
Network I/O in Node is not worker-pool-based. The libuv worker pool exists, but it's not what carries HTTP requests. Compare with fs.readFile from Part 5:
┌──────────────────────────────────────────┐ │ fs.readFile │ │ │ │ Strategy: delegate blocking work │ │ │ │ main JS thread │ │ └─ submit job │ │ └─ worker thread │ │ └─ open/read/close │ │ └─ may block in kernel │ │ └─ completion │ └──────────────────────────────────────────┘ ┌──────────────────────────────────────────┐ │ http server │ │ │ │ Strategy: readiness multiplexing │ │ │ │ main JS thread │ │ └─ register fd with epoll │ │ └─ epoll_wait │ │ └─ kernel signals readiness │ │ └─ Node reads bytes │ │ └─ parser → JS │ │ │ │ No worker thread used. │ └──────────────────────────────────────────┘
§ 35 · Are epoll, kqueue, or IOCP Involved?Yes · every step that says "Node is waiting for the network" is a call into one of these
Whichever OS you are on, the pattern is the same from Node's perspective: "I told the OS what fds I care about. The OS told me when something happened. Then I responded."
┌──────────────────────────────────────────┐ │ Linux │ │ │ │ epoll_wait blocks main JS thread │ │ kernel marks fds ready │ │ thread wakes, reads each ready fd │ └──────────────────────────────────────────┘ ┌──────────────────────────────────────────┐ │ macOS / BSD │ │ │ │ kevent blocks main JS thread │ │ kernel marks events ready │ │ thread wakes, processes each event │ └──────────────────────────────────────────┘ ┌──────────────────────────────────────────┐ │ Windows │ │ │ │ Node submits overlapped operations │ │ Kernel completes them │ │ Completion port wakes thread │ │ thread processes completions │ └──────────────────────────────────────────┘
§ 36 · Can the Main JS Thread Be Blocked?Yes · but not by the network operations themselves
The main JS thread can still be blocked by:
- CPU-heavy JavaScript inside your handler
- synchronous fs APIs (
fs.readFileSync, etc.) - huge
JSON.parse/JSON.stringify - regex catastrophic backtracking
- infinite loops
- blocking native addons
Time ───────────────────────────────────────────────────►
Main JS thread:
[handler request A: huge loop ............................] [handler B] ...
Network requests B, C, D arrive:
[accepted by kernel, queued in recv buffers]
[their callbacks WAITING for main thread...]
epoll has events ready, but the JS stack is busy.§ 37 · Wrong Mental Model vs CorrectEight common takes — and what's actually true
epoll/kqueue/IOCP directly from the main JS thread.accept() blocks waiting for connections.accept(), the kernel has already completed the handshake and queued the connection. accept() returns immediately. The waiting was done by epoll_wait (or equivalent), not by accept itself, and even epoll_wait wakes the moment something is ready.req and res are different connections.req and res are JS stream wrappers over the SAME underlying TCP socket. req is the readable side; res is the writable side; both reference one socket.res.end() guarantees the browser has received the data.res.end() hands the bytes to the kernel send buffer. TCP delivers them, eventually. Node has no direct confirmation of browser receipt at the application layer.llhttp) that runs in your Node process. The OS only knows about TCP bytes. HTTP is interpreted entirely in user space.server.listen blocks until a connection arrives.server.listen returns almost immediately. It just sets up the listening socket and registers it with epoll. The 'request' callback runs much later, when actual connections occur.Correction summary
┌────────────────────────────────┬───────────────────────────────────────┐ │ Wrong │ Correct │ ├────────────────────────────────┼───────────────────────────────────────┤ │ thread per request │ one main JS thread, kernel concurrency│ │ network uses worker pool │ network uses epoll/kqueue/IOCP │ │ accept() blocks │ accept() returns from queued conn │ │ req and res are separate conns │ they wrap the same socket │ │ res.end means delivered │ res.end means handed to kernel │ │ parser is in OS │ parser is llhttp inside Node │ │ listen() blocks │ listen() returns immediately │ │ handlers run in parallel │ handlers run interleaved on one thread│ └────────────────────────────────┴───────────────────────────────────────┘
§ 38 · The Compressed End-to-End SummaryFive stages · the whole story
The story compressed to its essential five stages. Each stage hides several syscalls and a kernel state transition, but if you can recite these five lines you have the model.
┌──────────────────────────────────────────┐
│ 1. listen() → kernel listening socket │
│ → epoll watches it │
└─────────────────────┬────────────────────┘
▼
┌──────────────────────────────────────────┐
│ 2. browser handshake → kernel accepts │
│ epoll signals → libuv accept() │
└─────────────────────┬────────────────────┘
▼
┌──────────────────────────────────────────┐
│ 3. browser sends request → kernel buffer │
│ epoll signals → libuv read() │
└─────────────────────┬────────────────────┘
▼
┌──────────────────────────────────────────┐
│ 4. llhttp parses → req, res created │
│ 'request' event → your handler runs │
└─────────────────────┬────────────────────┘
▼
┌──────────────────────────────────────────┐
│ 5. res.end → uv_write → kernel send buf │
│ TCP transmits → browser receives │
└──────────────────────────────────────────┘§ 39 · Two Machines · Three Worlds EachJS · native · OS — on each side
The three-world model applies to both sides of the conversation. The browser machine and the server machine each have their own JS world, native world, and OS world. Bytes only travel between them at the OS layer.
Browser machine
┌──────────────────────────────────────────┐
│ Browser JS / app world │
│ │
│ - fetch / XHR / navigation │
└─────────────────────┬────────────────────┘
▼
┌──────────────────────────────────────────┐
│ Browser native HTTP / TCP world │
│ │
│ - HTTP impl │
│ - TLS impl │
│ - connection pool │
└─────────────────────┬────────────────────┘
▼
┌──────────────────────────────────────────┐
│ Browser OS / kernel / hardware world │
│ │
│ - sockets │
│ - TCP / IP │
│ - NIC / loopback │
└──────────────────────────────────────────┘Server machine
┌──────────────────────────────────────────┐
│ Server JS world │
│ │
│ - your handler │
│ - req, res │
│ - http.Server │
└─────────────────────┬────────────────────┘
▼
┌──────────────────────────────────────────┐
│ Server Node native / libuv world │
│ │
│ - TCPWrap, llhttp │
│ - libuv handles │
│ - event loop, epoll/kqueue/IOCP glue │
└─────────────────────┬────────────────────┘
▼
┌──────────────────────────────────────────┐
│ Server OS / kernel / hardware world │
│ │
│ - listening socket │
│ - per-connection sockets │
│ - send / receive buffers │
│ - TCP / IP / NIC / loopback │
└──────────────────────────────────────────┘Between them: bytes on a wire, or memory in a loopback path.
§ 40 · The Full Invariant — Final Lock-this-inTen points · the whole model in a list
If you forget every diagram in this lesson, remember these ten lines. Each one collapses a section of the trace down to a single invariant.
- The kernel owns and drives every TCP connection.
- libuv asks the kernel — via
epoll/kqueue/IOCP— to be notified when any registered socket needs attention. - When a notification arrives, the main JS thread reads bytes (or accepts a new connection).
- It feeds those bytes to
llhttp. - It builds
req/resJS objects. - It runs your handler.
- It writes response bytes back to the kernel.
- It returns to the event loop and sleeps in
epoll_waitagain. - There is no request-per-thread. There is no spin-waiting. There is no busy polling.
- One thread, one event loop, and the kernel doing the heavy lifting.
server.listen(3000) registers and returns. req and res are stream wrappers over the same socket. Your handler runs on the main JS thread. res.end() hands bytes to the kernel — TCP and the kernel are responsible for delivery. That is the full browser HTTP request → Node server response story.