TogetherOS + Networkingevent loopepollkqueueIOCPnon-blocking

The Event-Driven Server

One thread, many non-blocking sockets, and a kernel API that says which ones are ready: the server sleeps until something happens and then does exactly the work that is possible — as long as nothing in it ever blocks.

ConceptualLinuxNode.js
Interview question
Progress

The problem

A thread pool caps threads but each worker still sleeps on one client; a non-blocking poll loop never sleeps but burns a core asking. What is needed is a way for one thread to sleep until any of ten thousand sockets has something to do, and then to be told which. That primitive exists in every kernel; the event loop is the program built around it.

Readiness instead of threads

Linux

The kernel already knows when a socket becomes readable — it is the same wake-up that unblocks recv() in Follow send() Through the OS to recv(). I/O Multiplexing: select, poll, epoll, kqueue, IOCP exposes that knowledge: register a set of descriptors with an epoll instance (Linux), a kqueue (BSD, macOS) or an I/O completion port (Windows — a completion model, where you learn an operation *finished* rather than that it *could start*), then make one blocking call that returns when any of them is ready. The thread sleeps in that call; when it returns it holds a list of exactly the sockets with work.

Every socket is set non-blocking, so handling a ready socket means calling recv() until it returns EAGAIN, then moving on — never sleeping inside a handler. The cost per idle connection is a kernel entry in the interest set (a few hundred bytes) plus whatever the application keeps; the cost per event is one loop iteration. Ten thousand idle connections cost one sleeping thread. This is the C10K answer, and the reason C10K: Ten Thousand Connections, Then a Million stopped being a problem around the time epoll and kqueue shipped.

epoll is O(1) per ready event because the kernel maintains the ready list as sockets change state; select/poll are O(n) per call because the caller passes the whole set every time and the kernel scans it. At 10,000 sockets that difference is the difference between a server and a space heater. Level-triggered vs edge-triggered mode changes when a socket is reported (while ready vs on becoming ready) and is the source of most epoll bugs.

The event loop
returns ready fdslisten fdreadablewritableheavy workcompletion eventloopepoll_wait / keventReady listDispatchaccept handlerread handlerwrite handlerWorker pool (CPU / file I/O)
UserLLMAgentToolDataDecisionHumanGuardrail

The loop, and the state it must carry

Conceptual

With threads, a connection’s state lived on the thread’s stack: "I have read the headers, I am waiting for the body". In an event loop the stack unwinds after every event, so the state must live in an explicit per-connection object: the bytes read so far, the parser’s position, the response not yet written. Every handler is "given the event and the connection state, advance the state machine one step, return". This inversion of control is the real cost of the model — the code is a state machine, and stack traces no longer tell a story.

Coroutines put the story back. async/await in JavaScript and Python, Rust futures, Kotlin coroutines, let the compiler or runtime turn the state machine back into sequential-looking code: await is the point where the handler returns to the loop and its locals are saved for later. Underneath it is still an event loop; the The Event Loop lesson covers how JavaScript’s loop, microtasks and the libuv thread pool fit together, and Async I/O: What `await readFile()` Actually Does covers the general form.

Writes need the same care as reads. A handler that calls send() with more than the send buffer can take gets a short write or EAGAIN; it must keep the remainder in the connection state and ask the loop for a writable event. Forgetting this is the most common bug in hand-written event servers and the reason frameworks own the write path — see What Happens When the Receiver Is Slow.

Node.js: the loop is libuv’s; handlers are callbacks or awaits (Node.js scope)
1import net from 'node:net'
2const server = net.createServer((sock) => { // one event per connection
3 let buffered = '' // per-connection state, not a stack
4 sock.on('data', (chunk) => { // readable event → handler returns fast
5 buffered += chunk
6 const end = buffered.indexOf('\r\n\r\n')
7 if (end === -1) return // wait for the next event
8 const ok = sock.write('HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok')
9 if (!ok) sock.once('drain', () => sock.end()) // send buffer full: wait for writable
10 else sock.end()
11 })
12})
13server.listen(8080)

Never block the loop

Conceptual

One thread serves every connection, so a handler that takes 200 ms takes 200 ms from every connection. A synchronous file read (fs.readFileSync, a plain open().read() in an asyncio handler), a large JSON.parse, a regular expression with catastrophic backtracking, a tight loop over a big array, a synchronous DNS lookup, a blocking call into a native library — each stalls the loop for its duration, and during that time no new connections are accepted, no responses are written, and every timer is late. The kernel keeps filling receive buffers; the application is not reading; senders see rwnd shrink. See the challenge Timers fire late, health checks fail, one core at 100%.

The rule is absolute because the failure is total and invisible from inside the handler. Diagnose it from outside: event-loop lag (the delay between a timer’s due time and its firing) is the metric; Node exposes it via perf_hooks.monitorEventLoopDelay, asyncio via a debug mode that logs slow callbacks. A loop with 50 ms lag is a server whose every request is 50 ms slower than it needs to be, regardless of the network.

Disk I/O deserves its own warning: on Linux a regular file is always reported ready by epoll, because the page cache will satisfy the read — eventually, after a disk seek that blocks the caller. Readiness does not apply to files. Runtimes hide this by doing file reads on a thread pool (libuv’s default of 4 threads) or, on recent Linux, with io_uring — see Memory Mapping, the Page Cache and Network I/O.

  • CPU work over ~1 ms, any synchronous I/O, and any call into code you do not control must leave the loop thread.
  • Measure event-loop lag continuously; it is the single number that predicts p99 for an event-driven service.
  • Regular files are not multiplexable on Linux; file I/O goes to a thread pool or io_uring.

Who uses it, and the hybrid everyone ends up with

Conceptual

nginx runs one event loop per worker process, one worker per core, and handles hundreds of thousands of connections per box; it does almost no CPU work per request — routing, buffering, sendfile — which is exactly the profile the model rewards. Redis runs a single event loop for command processing and gets its throughput from the fact that every command is microseconds; a slow command (KEYS * on a large keyspace) stalls every client, which is why the documentation is so insistent about it. Node.js, HAProxy, Envoy, Netty, Tokio, asyncio, Go’s netpoller — all the same shape.

None of them are purely event-driven. CPU-heavy work — TLS handshakes in some designs, compression, image processing, request parsing for big bodies, any blocking library — goes to a worker pool, and the pool posts a completion event back to the loop. The The Thread Pool Server returns as a component: the loop does I/O and dispatch; the pool does work; a queue sits between them with the sizing and overflow questions from that lesson. Node’s libuv pool, nginx’s thread pools for file I/O, Envoy’s per-worker threads with a shared dispatcher, Go’s runtime with its M:N scheduler over a netpoller are all versions of this hybrid.

The honest comparison: an event loop wins when connections are many and mostly idle and per-event work is small; a thread pool wins when work per request is large and CPU-parallel and connections are few; a runtime with cheap threads over a netpoller lets you write the second and run the first. Choose by the shape of the workload, not by the fashion of the decade.

Event loop vs thread pool vs cheap-thread runtime
PropertyEvent loopThread poolGreen threads over netpoller
Idle connection costkernel entry + app state (~kB)a parked OS thread (~50–100 kB)a parked coroutine (~kB)
CPU-heavy handlerstalls everyone; must offloaduses a worker; parallel across coresruntime preempts or pins a carrier thread
Blocking library callstalls everyoneblocks one workerblocks an OS thread; runtime compensates
Programming modelcallbacks / async-await state machinesequentialsequential
Debuggabilitylag metrics, no stack storythread dumps tell the storyruntime-specific tooling
Typical usersnginx, Redis, Node, HAProxy, EnvoyTomcat, Rails/Puma, most business servicesGo, Java 21+, Erlang

Key points

  • The event loop is built on a kernel readiness (or completion) API: epoll, kqueue, IOCP. The thread sleeps in one call and wakes with the list of sockets that have work.
  • Sockets are non-blocking; handlers drain until EAGAIN and return. Per-connection state lives in explicit objects (or coroutine frames), not on a thread stack.
  • Never block the loop: any CPU-heavy or synchronous call stalls every connection. Event-loop lag is the metric that shows it.
  • Regular files are not multiplexable on Linux; file I/O belongs on a thread pool or io_uring.
  • Production servers are hybrids: a loop for I/O and dispatch, a worker pool for CPU work, a queue between them with all the thread-pool questions.
  • Choose by workload shape: many idle connections and small work favour the loop; large parallel work favours threads; cheap-thread runtimes let you write one and run the other.

Why does this exist?

Mechanisms are answers to constraints. Open each question before reading the answer.

Why do event loops exist?

Because the expensive thing is a blocked thread, and the kernel can tell you which sockets are ready without you parking a thread on each. One sleeping thread that wakes with a ready list replaces ten thousand sleeping threads. The programming model is the cost of that trade.

Why is "never block the loop" so absolute?

Because there is exactly one thread, and every connection’s progress depends on it. A blocked worker in a pool degrades capacity by 1/N; a blocked loop degrades it by 100% for the duration, and nothing inside the handler can tell.

Why does epoll scale where select does not?

select passes the whole descriptor set on every call and the kernel scans it: O(n) per call, and n calls per second becomes O(n²) work. epoll keeps the interest set in the kernel and maintains a ready list as sockets change state, so each call costs only the number of ready events.

Why do event-driven servers still have thread pools?

Because some work cannot be made non-blocking — CPU computation, file I/O on Linux, third-party libraries — and the loop must not do it. The pool is where that work goes; the loop only waits for its completion event.

How it fails

What the failure looks like from inside real software.

  • A JSON.parse of a 40 MB request body in a Node handler: every other request’s latency jumps by the parse time; event-loop lag alarms; the kernel’s receive buffers fill and clients’ rwnd goes to zero.
  • Redis KEYS * or a Lua script over a large keyspace: all clients stall for its duration; the symptom is a latency spike across every service that shares the instance.
  • A handler writes a large response with send() in a loop ignoring short writes: under a slow client the loop either busy-spins on EAGAIN or silently drops the tail.
  • Edge-triggered epoll without draining to EAGAIN: a socket that still has data is never reported again; the connection hangs with data sitting in the receive buffer.
  • Blocking DNS resolution inside the loop (getaddrinfo): a slow resolver stalls the whole server for the resolver timeout; nginx and Node both special-case this for that reason.
  • libuv’s 4-thread default pool saturated by slow file reads: file-backed requests queue behind each other while the loop itself looks healthy.