Event Loops and Non-Blocking I/O

An event loop is a single receptionist with a to-do tray.

Learning Objectives

Explain what an event loop does: ready queues, callback queues, task scheduling, timers and I/O notification.

Compare polling and completion I/O; select, poll, epoll, kqueue and IOCP; readiness versus completion models; non-blocking sockets.

Describe callback-based and coroutine-based loops; the browser loop with tasks and microtasks; the Node.js loop; Python asyncio; Java NIO; .NET async I/O.

Diagnose event-loop starvation, blocking calls and long CPU tasks; offload to pools; understand timer inexactness, re-entrancy, callback ordering, shutdown with pending callbacks, debugging, stream backpressure and connection scalability.

Plain-Language Explanation

An event loop is a single receptionist with a to-do tray. They take the top item, handle it quickly, put it aside, and take the next. When a phone rings or a timer goes off, a new item lands in the tray. Because there is only one receptionist, nothing happens in parallel — but nobody waits idly either, as long as every item is quick. If one item takes an hour, everyone else waits an hour. That is the whole art of event-loop programming: never hold the receptionist.

Precise Technical Explanation

What an event loop does Callbacks/tasks that can run now. The loop drains them in order (some loops have several queues with priorities).

Queues of functions registered for events: timer callbacks, I/O callbacks, "call soon" callbacks.

Coroutine-based loops wrap each coroutine in a task; resuming a task is "run its next step" — a callback in the ready queue.

A priority queue ordered by deadline. When the loop wakes, expired timers' callbacks become ready.

I/O readiness notification The loop asks the OS which file descriptors/sockets are ready to read or write, then runs their callbacks — without blocking on any single one.

Polling and completion-based I/O Readiness (reactor): the OS says "socket 7 is readable now"; the program then performs the non-blocking read. select, poll, epoll (Linux), kqueue (BSD/macOS).

Completion (proactor): the program starts an operation with a buffer; the OS performs it and reports "your read of 512 bytes finished". Windows IOCP, Linux io_uring.

Select, poll, epoll, kqueue and IOCP concepts Scales to many sockets?

no (fd limit, O(n) scan) yes (O(ready)) readiness (+ more event types) yes, fewer syscalls

O_NONBLOCK/setblocking(False): recv returns immediately with data or EAGAIN/EWOULDBLOCK. Essential for readiness-based loops so one slow client cannot block others.

Readiness versus completion models Readiness needs a buffer only when data is ready (memory-efficient) but costs an extra syscall; completion hands buffers to the kernel in advance (fewer syscalls, more memory, natural on Windows). Libraries such as libuv and Tokio hide the difference.

Callback-based event loops libuv (Node.js), libevent, Boost.Asio (with callbacks), Netty. Each I/O completion invokes a callback.

Coroutine-based event loops Python asyncio, Rust Tokio, Kotlin coroutines, C++20 coroutine frameworks: the loop resumes coroutines instead of calling arbitrary callbacks.

Browser event loop Each window/worker has an event loop: take one task (macrotask: script, timer, event, message), run it to completion, then drain the microtask queue completely, then possibly render (≈ every 16 ms at 60 Hz), repeat.

JavaScript tasks and microtasks Microtasks (promise reactions, queueMicrotask, MutationObserver) run after the current task and before the next task — all of them, including microtasks queued by microtasks. Timers (setTimeout) are tasks with a minimum delay (nested timeouts are clamped to ≥ 4 ms in browsers).

Node.js event loop overview libuv phases: timers → pending callbacks → idle/prepare → poll (I/O) → check (setImmediate) → close callbacks. Between each callback, Node drains process.nextTick callbacks, then promise microtasks. Inside an I/O callback, setImmediate always runs before setTimeout(…, 0) (shown below).

Python asyncio event loop asyncio.run creates a loop (default: selector-based on Unix, proactor/IOCP-based on Windows); call_soon, call_later, tasks, transports/protocols and streams; debug mode reports callbacks that take too long.

Java NIO and asynchronous APIs overview java.nio.channels.Selector (readiness, used by Netty), AsynchronousSocketChannel (completion handlers), HttpClient.sendAsync returning CompletableFuture; since Java 21 virtual threads let blocking-style code scale instead (Module 15)..NET async I/O overview

Socket/Stream ReadAsync/WriteAsync built on IOCP (Windows) or epoll/kqueue (Unix) inside the runtime; completions resume awaits on the thread pool. There is no single user-visible event loop.

The loop cannot process timers or I/O while a callback runs. Starvation sources: CPU-heavy callbacks, blocking calls, endless microtask chains (a microtask that keeps queuing microtasks starves all tasks, including rendering), huge synchronous JSON parsing.

Blocking calls on an event-loop thread Synchronous file I/O (fs.readFileSync), time.sleep, DNS lookups in some libraries, synchronous database drivers, Atomics.wait on the browser main thread (forbidden).

Long-running CPU tasks Split them into chunks that yield (await / setImmediate between chunks) or move them off the loop.

Offloading work to a thread/process pool asyncio.to_thread/loop.run_in_executor (Python), worker threads (Node worker_threads, Web Workers), Task.Run (.NET), spawn_blocking (Tokio). The loop awaits the result without blocking.

Timers are not exact real-time guarantees A timer fires *no earlier* than requested, then waits for the loop to get around to it — later under load, clamped in browsers, throttled in background tabs, coarse on some OSes (Windows default timer resolution ~15.6 ms). Never use timers for precise scheduling.

Re-entrancy and callback ordering A callback may cause events whose callbacks run later, never in the middle of the current one (run-to-completion) — but code that calls user callbacks *synchronously* (some event emitters) can re-enter itself. Libraries should be consistently asynchronous ("don't release Zalgo").

Shutdown and pending callbacks Node exits when nothing keeps the loop alive (no pending timers, sockets or handles; unref() excludes a handle). asyncio.run cancels remaining tasks at the end of main. Graceful shutdown: stop accepting, finish or cancel in-flight work, close resources, then let the loop exit.

Measure loop lag (schedule a timer, compare actual delay — shown below), Node's perf_hooks.monitorEventLoopDelay, asyncio debug mode (PYTHONASYNCIODEBUG=1 / asyncio.run(debug=True)), Chrome DevTools performance panel (long tasks), clinic.js.

Backpressure in stream APIs Node streams: write() returns false when the internal buffer exceeds highWaterMark; wait for 'drain' (or use pipeline, which handles it). asyncio streams: await writer.drain(). Web Streams: the desiredSize and pull-based model. Ignoring backpressure buffers unbounded data in memory.

Server connection scalability One event-loop thread can serve tens of thousands of mostly idle connections (the C10K problem solved by epoll/kqueue/IOCP). Throughput then depends on per-request CPU; multi-core servers run one loop per core (nginx workers, Node cluster, Tokio's multi-threaded runtime).

pseudocode
loop:
    run every ready callback/task (each runs to completion or to its next await)
    compute the time until the next timer
    wait for I/O readiness/completion events (but no longer than that time)
    move callbacks for fired timers and ready I/O into the ready queue

Why It Matters in Real Systems

Node.js, nginx, Redis, browsers, Python asyncio services and most GUI frameworks are event-loop systems. Their characteristic outage is a single slow callback freezing everything.

Key Terms

Loop that dispatches ready callbacks and waits for events Task (macrotask) One unit of work taken by the loop (JS)

High-priority callback drained after each task (JS) Readiness / completion OS reports "ready" / "done" epoll / kqueue / IOCP Scalable OS event-notification APIs

Delay between when a callback should run and when it runs A callback is never interrupted by another on the same loop

Buffer threshold that triggers stream backpressure

Diagram or Execution Timeline

eventloop

Syntax and API Overview

The specification's microtask example (Node.js 24, which follows the HTML-style ordering for these APIs):

In a typical browser and in Node.js the order is A, B, microtask, timer: the script runs to completion; then the microtask queue is drained; the timer callback is a separate task that becomes eligible after its (minimum) delay. setTimeout(…, 0) means "eligible after at least 0 ms (browsers clamp nested timers to 4 ms)", never "immediately".

console.log("A");

setTimeout(() => console.log("timer"), 0);
Promise.resolve().then(() => console.log("microtask"));

console.log("B");
A
B
microtask
timer

Complete Code Example

Node.js 24 — process.nextTick, microtasks, setImmediate versus setTimeout inside an I/O callback (deterministic), loop lag caused by blocking work, and stream backpressure:

A readiness-based loop built by hand with Python's selectors (CPython 3.13.1; selectors.DefaultSelector uses select on Windows, epoll on Linux, kqueue on macOS), plus asyncio's debug-mode warning about a slow callback:

const fs = require("node:fs");
const { Writable } = require("node:stream");

fs.readFile(__filename, () => {                       // we are inside an I/O (poll phase) callback
  setTimeout(() => console.log("4 setTimeout 0 (timers phase, next loop iteration)"), 0);
  setImmediate(() => console.log("3 setImmediate (check phase, same iteration)"));
  Promise.resolve().then(() => console.log("2 promise microtask"));
  process.nextTick(() => console.log("1 process.nextTick (before promise microtasks)"));
});

setTimeout(() => {
  const expected = performance.now() + 20;
  setTimeout(() => {
    const lag = performance.now() - expected;
    console.log(`5 a 20 ms timer fired ~${Math.round(lag / 50) * 50} ms late: a 300 ms synchronous loop blocked the event loop`);
    backpressure();
  }, 20);
  const end = performance.now() + 300;
  while (performance.now() < end) {}                 // CPU-bound work on the loop thread
}, 50);

function backpressure() {
  const slow = new Writable({
    highWaterMark: 4,                                   // tiny buffer: 4 bytes
    write(chunk, enc, cb) { setTimeout(cb, 5); },       // a slow consumer
  });
  let accepted = 0, refused = 0;
  for (let 
…
1 process.nextTick (before promise microtasks)
2 promise microtask
3 setImmediate (check phase, same iteration)
4 setTimeout 0 (timers phase, next loop iteration)
5 a 20 ms timer fired ~300 ms late: a 300 ms synchronous loop blocked the event loop
6 stream: write() returned true 1 time(s), false 5 time(s) -> wait for 'drain'
7 'drain' emitted: safe to write again
import asyncio
import logging
import selectors
import socket
import sys
import time

sel = selectors.DefaultSelector()
print("selector:", type(sel).__name__)

pairs = [socket.socketpair() for _ in range(3)]          # (server_end, client_end) per "client"
for i, (srv, cli) in enumerate(pairs):
    srv.setblocking(False)
    sel.register(srv, selectors.EVENT_READ, data=f"client-{i}")

pairs[2][1].send(b"hello")                               # only clients 2 and 0 have sent anything
pairs[0][1].send(b"ping")
handled = 0
while handled < 2:
    for key, _ in sel.select(timeout=1):                 # wait for READINESS on any registered socket
        msg = key.fileobj.recv(1024)                     # will not block: the socket is ready
        print(f"ready: {key.data} sent {msg!r}")
        handled += 1
try:
    pairs[1][0].recv(1024)                               # non-blocking socket with no data
except BlockingIOError:
    print("client-1 not ready: recv raised BlockingIOError instead of blocking")
for srv, cli in pairs:
    sel.unregister(srv); srv.close(); cli.close()

logging.basicConfig(stream=sys.stdout, format="asyncio debug: %(message)s")
async def main():
    asyncio.get_run
…
selector: SelectSelector
ready: client-0 sent b'ping'
ready: client-2 sent b'hello'
client-1 not ready: recv raised BlockingIOError instead of blocking
asyncio debug: Executing <Task finished name='Task-1' coro=<main() done, defined at m11_py_selectors.py:32> result=None created at D:\EngineerHub-tools\python313-pkg\tools\Lib\asyncio\runners.py:100> took 0.201 seconds

Line-by-Line Explanation

Node ordering inside an I/O callback: nextTick queue, then promise microtasks, then the check phase (setImmediate) of the same loop iteration, then the next iteration's timers phase. (At the top level of a script the setTimeout(0)/setImmediate order is *not* deterministic — it depends on process timing — which is why this example schedules them from an I/O callback.)

Loop lag: the 20 ms timer cannot fire while the 300 ms busy loop holds the thread; it fires roughly 300 ms late.

Backpressure: with a 4-byte highWaterMark, write() starts returning false; the data is still buffered (not lost), but a well-behaved producer stops until 'drain'.

selectors: three non-blocking sockets registered for read readiness; select returns only the ready ones, so the loop handles clients 2 and 0 without ever blocking on client 1; reading client 1 directly raises BlockingIOError.

asyncio debug mode logs any callback/task step that runs longer than slow_callback_duration.

Output

Shown under each program. Rounded timings come from one real run; orderings are deterministic as explained.

Interleaving or State Trace

The specification example:

Timer/task queue script: setTimeout timer (eligible ≥ 0 ms) script: .then (empty) → drain microtasks timers phase → timer task

Correctness and Safety Analysis

Run-to-completion means a callback's synchronous code is never interrupted by another callback on the same loop — no locks are needed for state touched only by loop callbacks. The price: correctness *and* latency depend on every callback being short and non-blocking.

Common Mistakes and Misconceptions

"setTimeout(fn, 0) runs immediately." "Promises run in parallel."

Recursive process.nextTick/microtask loops (they starve I/O and timers).

Synchronous I/O or CPU-heavy work in request handlers.

Ignoring write()'s return value in Node streams.

Assuming top-level setTimeout(0) vs setImmediate order in Node.

Failure Modes and Debugging Steps

Measure event-loop lag (monitorEventLoopDelay, a heartbeat timer, asyncio debug).

Profile the slow callback (--cpu-prof, DevTools, py-spy).

Move CPU work to workers/threads/processes, replace blocking calls, or chunk the work.

For memory growth in streams, check backpressure handling.

Performance and Scalability Implications

An event loop handles huge numbers of idle connections cheaply but uses one core; scale out with one loop per core. Throughput is bounded by the slowest callbacks; p99 latency by the longest one.

Security Considerations

A single request that triggers expensive synchronous work (a regular-expression with catastrophic backtracking — ReDoS — or a huge JSON body) blocks every other client: validate input sizes and use timeouts and worker offloading for risky operations.

Platform and Runtime Caveats

Microtask/task semantics are defined by the HTML standard for browsers; Node.js adds process.nextTick and libuv phases.

Python asyncio on Windows defaults to the proactor (IOCP) loop; selectors on Windows only supports sockets (not pipes or files).

Timer resolution and clamping differ between browsers (4 ms nesting clamp, background-tab throttling) and OSes.

Real-World Applications

nginx and Node.js servers, Redis's single-threaded command loop, browser rendering, game loops, GUI toolkits, IoT gateways.

Best Practices

Keep every callback/await step short; offload CPU and blocking work.

Monitor loop lag in production.

Respect stream backpressure (pipeline, drain).

Do not depend on timer precision.

Make libraries consistently asynchronous.

Exercises

E1 · Beginner. Predict the output order: setTimeout(()=>log("t"),0); queueMicrotask(()=>log("m")); log("s"); Answer: s, m, t.

E2 · Intermediate. A Node.js API's p99 latency jumps to 2 s whenever a report endpoint is called. Diagnose.

Answer: The report endpoint likely does a long synchronous computation (or readFileSync, large JSON.parse) on the event loop; while it runs, every other request waits. Confirm with loop-lag metrics and a CPU profile; move the work to a worker thread or stream/chunk it.

E3 · Advanced challenge. Explain why while (true) await Promise.resolve(); freezes a browser page even though it "awaits".

Answer: Each await of an already-resolved promise only queues a microtask, and the microtask queue is drained completely before the loop moves to the next task — so rendering, input and timers never get a turn.

Quiz

? In Node.js, inside an fs.readFile callback, which runs first: setImmediate or setTimeout(…, 0)?
+ setImmediate :: The check phase follows the poll phase in the same loop iteration.
- setTimeout :: Timers run in the next iteration's timers phase.
- It is random :: Inside an I/O callback the order is deterministic.
- They run in parallel :: The loop is single-threaded.

? What does epoll report?
+ Which registered file descriptors are ready for reading or writing :: A readiness model.
- That a read operation has completed into your buffer :: That is the completion model (IOCP, io_uring).
- Which thread holds a lock :: Unrelated.
- The CPU usage of each socket :: Unrelated.

? Which starve a JavaScript event loop? (select all that apply)
+ A 2-second synchronous loop in a callback :: Nothing else can run meanwhile.
+ A microtask that keeps scheduling new microtasks :: Tasks and rendering never get a turn.
- Awaiting a 2-second network request :: The loop is free while waiting.
+ fs.readFileSync of a huge file in a request handler :: Synchronous I/O blocks the thread.

Summary

An event loop runs ready callbacks to completion, waits for timers and I/O with scalable OS APIs (readiness: epoll/kqueue; completion: IOCP/io_uring), and repeats. Microtasks run before the next task; Node adds nextTick and libuv phases; timers are lower bounds. Never block the loop: offload CPU and blocking work, respect stream backpressure, and monitor loop lag.

Further Reading

HTML Standard, "Event loops"; Jake Archibald, "Tasks, microtasks, queues and schedules".

Node.js docs: "The Node.js Event Loop", "Backpressuring in Streams".

Python docs: "Developing with asyncio", selectors.

Dan Kegel, "The C10K problem"; man 7 epoll.