Guide

An event loop is a single thread that repeats one cycle: run the ready callbacks, then wait for new events. The events come from the kernel, such as "socket readable" or "timer fired". The callbacks are your code. The loop never blocks on one socket, so it can serve many connections, as long as every callback returns quickly.

That last condition is the whole discipline. The loop is only as responsive as its slowest callback.

Walking through it

Each pass of the loop goes through a few steps. The details differ by runtime, but the shape is the same:

  1. Timers. Run callbacks whose delay has passed.
  2. I/O callbacks. Run the callbacks for sockets the kernel reported as ready.
  3. Pending work. Run deferred callbacks from the previous pass.
  4. Poll. Ask the kernel for new events, sleeping only if nothing is ready and no timer is due.

Node.js's libuv follows a close variant of this loop. Python's asyncio uses the same model, with a ready queue of tasks instead of raw callbacks.

Walking through it: one slow callback

This example runs three tasks on one loop. Each one awaits a delay, which yields control back to the loop while it waits. The loop runs them together.

Python
import asyncio, time

async def fetch(name, delay):
    await asyncio.sleep(delay)            # yields to the loop while waiting
    return name

async def main():
    t0 = time.perf_counter()
    results = await asyncio.gather(fetch("a", 0.1), fetch("b", 0.2), fetch("c", 0.3))
    print(results, round(time.perf_counter() - t0, 1))

asyncio.run(main())

Output:

['a', 'b', 'c'] 0.3

Three waits of 0.1, 0.2 and 0.3 seconds finish in 0.3 seconds, not 0.6. Each wait releases the loop to run the others. Now replace asyncio.sleep with time.sleep. The total becomes 0.6 seconds, because time.sleep blocks the one thread and nothing else can run. The same is true for a CPU-bound loop or a synchronous database call inside a handler.

Warning

A callback that blocks is the most common cause of latency spikes in Node.js and asyncio services. Health checks time out, and requests queue up behind one slow handler, even though the CPU is mostly idle.

Handling slow work

Three options work well:

  • Use an async driver for databases and HTTP, so each call yields while it waits.
  • Move CPU work to a worker pool. Node's worker threads and Python's run_in_executor keep the loop free for I/O.
  • Break long loops into chunks, yielding between them, so timers and I/O still get a turn.

Measure the loop's lag. A lag metric, the time between when a timer should fire and when it actually does, shows how long a callback held the loop.

Common mistakes

  • Parsing a large JSON payload or running a regex with catastrophic backtracking on the loop.
  • Using a synchronous library inside an async handler, such as a file read or a blocking client.
  • Trusting average latency while the loop lag, the actual signal, goes unmeasured.

Check yourself

  • Why does a 0.3-second asyncio.sleep in three tasks finish in 0.3 seconds, but a blocking sleep doesn't? Answer: await yields to the loop, so the others run during the wait. A blocking call holds the only thread.
  • Why is loop lag a better signal than average latency here? Answer: a single blocked callback delays every request in the queue, and the lag metric measures that delay directly.

Key takeaways

  • An event loop runs ready callbacks and waits for the kernel's events.
  • Every callback must return quickly, because one slow callback blocks all others.
  • Use async drivers, push CPU work to workers, and measure loop lag.

Further learning

Next, see async and await state machines to see what await does to the function.