skip to content

What is an event loop, and what does run-to-completion mean for the callbacks it dispatches?

level: juniorimportance: must knowfreq 60%

answer

  1. wait for readiness, drain queue, run handler to completion
  2. no preemption between handlers
  3. loop-owned state needs no locks
  4. latency = sum of work queued ahead
  5. atomic unit = one handler, not one operation

basics

~20 s

An event loop is a single thread cycling forever: wait for ready events, take the next task from a queue, run its handler to completion, repeat. No handler is ever interrupted mid-way by another handler, so loop-owned state needs no locks — but a slow handler delays everything behind it.

solid answer

~50 s

An event loop is a thread running `while (running) { events = waitForReady(timeout); enqueue(events); runNextTasks(); }`. The defining property is **run-to-completion**: once a handler starts, it runs until it returns. The loop cannot preempt it to service another event. Everything else waits in the queue. That buys a lot. State owned by the loop needs no synchronisation, because two handlers never execute at once. Reasoning is easier: within a handler nothing else mutates your data. Memory per concurrent connection is a small state object, not a thread stack, so one loop can hold very many connections. It costs one thing, and it is the thing that bites: **queueing delay is additive**. Response latency for a queued event is the sum of the work ahead of it. One 300 ms handler adds 300 ms to every pending event. So handlers must be short and must never block — no synchronous I/O, no waiting on a lock, no long CPU loops.

go deeper

for a junior

Describe the loop cycle and run-to-completion, and give the consequence: a slow handler delays every other pending event.

for a middle

Add where events come from, why the readiness wait is the only sleeping point, and why the atomic unit is a handler rather than a logical operation.

for a senior

Discuss queueing latency as the operating characteristic, backpressure when arrivals exceed drain rate, and the rules about chunking CPU work.

for a principal

Frame it as a capacity model: per-connection cost becomes a small state object instead of a stack, so the constraint moves from thread count to per-event service time and tail latency discipline.

## The shape Stripped down, an event loop is: ``` while (running): timeout = timeUntilNearestTimerDeadline() readyEvents = waitForIoReadiness(timeout) # the only place we sleep for e in readyEvents: enqueue(e.handler) for t in expiredTimers(): enqueue(t.handler) while queue not empty and budget remains: task = queue.pop() task.run() # runs to completion; nothing preempts it ``` One thread. One queue (or a few, by priority/phase). Handlers are ordinary functions that return quickly. ## Run-to-completion When a handler is dispatched, it holds the loop until it returns. There is no preemption, no time slice, no interruption by another handler. Concurrency comes from *interleaving whole handlers*, not from interleaving instructions. This is the single most important property to state in an interview, because everything else follows from it: - **No locks for loop-owned state.** Two handlers cannot execute at once, so a data structure only ever touched from this loop needs no mutex and cannot suffer torn reads. This is a genuine simplification, not a shortcut. - **Invariants hold across a handler.** Anything you do between entry and return is atomic with respect to other handlers. - **But not across a continuation.** If a handler starts an async operation and the rest of the work happens in a later callback, other handlers run in between. So multi-step logic split across callbacks is *not* atomic — the atomic unit is one handler invocation, not one logical operation. - **Latency is queueing latency.** With handlers taking d1..dn ahead of you, your event waits sum(d1..dn) before it even starts. This is head-of-line blocking, and it is why "never block the loop" is the cardinal rule. ## Where the events come from Three families feed the queue: 1. **I/O readiness or completion** — the OS tells the loop which sockets, pipes, or files are actionable. The loop's single sleeping point is this wait call, and its timeout is computed from the nearest timer deadline so timers are not missed. 2. **Timers** — deadlines registered by the application; a timer whose deadline has passed becomes a task. 3. **Application-posted tasks** — work explicitly scheduled onto the loop, including completions handed back from other threads. ## Why the model exists Compare with thread-per-connection. Ten thousand mostly-idle connections cost ten thousand stacks and a scheduler with ten thousand entries, plus a kernel context switch each time one becomes active. With an event loop, each connection costs a small heap object holding its parse state, and the loop makes one readiness call to learn about all of them at once. For workloads dominated by waiting — proxies, chat, streaming, API gateways — that is a decisive difference in memory and switch overhead. The trade is that the model is only as good as its worst handler. Threads get fairness from the kernel whether the code cooperates or not; an event loop gets fairness only from handlers that stay short. ## The rules that fall out - **No synchronous I/O in a handler** — reading a file or a socket synchronously stalls every connection. - **No blocking waits** — acquiring a contended lock or waiting on another thread's result stalls the loop for that duration. - **Bound the work per handler** — parsing a 50 MB payload, sorting a large list, or looping over a big collection is a stall even though no I/O is involved. Chunk it and yield between chunks, or move it off the loop. - **Beware unbounded queues** — if events arrive faster than they are drained, the queue grows, latency grows with it, and memory follows. Backpressure has to come from somewhere: stop reading from slow sockets, shed load, or bound the accept rate. ## Common misconceptions to avoid The loop is not "asynchronous magic": nothing runs in parallel with your handler. A single loop uses one core; scaling beyond that needs multiple loops or a worker pool. And single-threadedness removes data races but not logical races that span a callback boundary — the atomic unit is a handler, not an operation.

  • If an event loop is single-threaded, why can a program still have a logical race?
    Because the atomic unit is a single handler invocation, not a whole logical operation. When work is split across a callback or continuation, other handlers run in the gap and can change the same state, so a check performed before the gap may be stale after it. Single-threading rules out torn reads, not interleaving between callbacks.
  • How does an event loop serve thousands of connections with one thread?
    It asks the operating system once for the set of connections that are actionable right now, rather than dedicating a thread to wait on each. Each idle connection costs only a small state object, so memory scales with connection count cheaply and there is no per-connection kernel context switch.

A single receptionist working a queue: they finish with one visitor completely before calling the next. Nobody's paperwork gets shuffled mid-conversation, but one visitor with a forty-minute problem makes the whole waiting room late.

saying these in an interview costs you the question

  • Saying handlers run in parallel or on multiple cores by default
  • Claiming an event loop can preempt a long-running handler
  • Believing single-threading removes all race conditions, including across callbacks
  • Assuming an event loop is always faster than threads, even for CPU-bound work
  • Ignoring queue growth: treating the task queue as if it were free and unbounded

context