Does every wait really need a person of its own? One clerk who places several calls and answers whichever bell rings can hold many conversations from a single seat.
See this first in FoundationsWhen does an event loop win, and when does it lose?Turn it around: must a thread hold the waiting? Write down "when this finishes, run that function" and go do other work. Waiting becomes a line in a list instead of a sleeping thread. That is an event loop.
Below, db.query() returns immediately and takes a function to run when the result arrives — a callback. The handler finishes instantly and the thread moves to the next request.
The second code box is the whole thing: pull a finished event off the queue, run its callback, repeat. A new connection is an event; a database reply is an event. The structure is startlingly simple.
Asking 10,000 sockets one by one whether data arrived would itself burn the CPU, so the OS offers "tell me which of these are ready" (epoll on Linux, kqueue on macOS). That is what made event loops practical.
Same request load, one thread. Completion is comparable or better, thread memory drops from 4.0 to about 1, and switches fall to near zero. This model does not count changing requests inside a loop as switching OS threads; a real loop thread can still be preempted or sleep while waiting for events.
If the loop checked every connection on each pass it would slow down in proportion to the connection count — which is exactly what select did. epoll and kqueue register the sockets you care about with the kernel and hand back only the ready ones. With ten thousand connections you receive just the ten that are ready. The event loop became practical only after this kernel facility existed.
A loop can accept requests very quickly. The question is whether it can finish them just as quickly. When intake outruns processing, pending callbacks and buffers accumulate until memory gives out. Reactive stacks therefore let a consumer request only as much as it can handle. That mechanism is backpressure.
1handle(req) {2 id = parse(req)① CPU works3 db.query(id, (user) => {② just registers a callback4 res.send(toJson(user))③ the loop runs this later5 })6} // returns immediately!
1while (true) {2 ev = queue.take() // finished IO, new requests...3 ev.callback()4}
Try it in the simulator
Terms in this chapter
Why it evolved this way
This is why nginx outperformed Apache and how Node.js and Redis hold tens of thousands of connections on a single thread: buy concurrency with a data structure instead of with threads.
Event loop = move the waiting out of threads and onto a list.
The remaining problem
Not free — the code shatters into callbacks. Let us fix that first →