awesome-everything RU
↑ Back to the climb

Backend Architecture

The event loop: one thread, ordered phases

Crux The event loop is one thread running queued callbacks in fixed phases — timers, poll, check, close — and draining a higher-priority microtask queue between each. That order explains setTimeout vs setImmediate vs Promise, and why the concurrency is cooperative.
Your altitude — climbing toward senior
ZeroJuniorMiddleSenior
You are at middle altitude — in the sky
◷ 13 min

A developer writes setTimeout(fn, 0) next to setImmediate(fn) next to Promise.resolve().then(fn), expecting them to fire in source order. They do not. The promise runs first, then — depending on where the code sits — either the timer or the immediate. This is not a bug or a race; it is the event loop’s published schedule. The loop is not a black box that “runs async stuff eventually.” It is a strict, ordered machine, and once you can see its phases, async timing stops being folklore.

The loop is a sequence of phases

The single thread from the last lesson does not run callbacks in arrival order. It cycles through a fixed sequence of phases (in libuv, Node’s loop engine), and each phase has its own queue of callbacks to drain before moving on. The ones that matter day to day:

  • Timers — callbacks whose setTimeout/setInterval delay has elapsed.
  • Poll — the heart: retrieve new I/O events and run their callbacks (an HTTP request arrived, a file read finished). If nothing is pending, the loop blocks here waiting on epoll/kqueue — this is where idle time is spent.
  • Check — callbacks scheduled with setImmediate, run right after poll.
  • Close — close-event callbacks (socket.on('close')).

One full trip through the phases is a tick of the loop. Within a phase, callbacks run one at a time, to completion — there is no preemption.

Macrotasks vs microtasks

Two of those words hide the whole timing model. The phase queues hold macrotasks: a timer callback, an I/O callback, a setImmediate. Separate from them are two microtask queues with higher priority:

  • process.nextTick callbacks (Node-specific), drained first.
  • Promise reactions (.then/await continuations).

The rule: after every single callback finishes, the loop fully drains the microtask queues before running the next callback or advancing a phase. That is why Promise.resolve().then(fn) beats a setTimeout(fn, 0) — the promise reaction is a microtask that runs at the next drain, while the timer waits for the timers phase on a later tick.

Why this works

Why a separate, higher-priority microtask queue at all? Because promise chains need to settle before the program returns to the I/O scheduler, or ordering would be unpredictable across ticks. Microtasks let a unit of synchronous-looking async work (an await resolving, then the next await) finish as a group before the loop does anything else. The danger is the mirror image: a microtask can schedule another microtask, which schedules another — an unbounded chain (process.nextTick recursion or a runaway promise loop) starves the loop, because microtasks are drained to empty before any I/O callback runs. So microtasks give you tight ordering, but a microtask flood can block the poll phase just as hard as a synchronous loop — the loop never gets to its sockets.

setTimeout(0) vs setImmediate

These two are the classic confusion. setImmediate always runs in the check phase, right after poll; setTimeout(fn, 0) is clamped to a ~1 ms minimum and runs in the timers phase. Inside an I/O callback (i.e., already in or just past poll), setImmediate reliably fires before the next timers phase, so it wins. At the top level of a script, the order is not guaranteed — it depends on how much time the loop spent starting up. The senior takeaway is not the trivia but the model: “immediately after I/O” and “after the timer delay” are different phases, not the same queue.

Cooperative, not parallel

Because one thread runs each callback to completion with no preemption, the event loop’s concurrency is cooperative: every callback must voluntarily yield — by finishing, or by hitting an await that hands control back — for any other callback to run. Two requests are never executing JavaScript at the same instant; they interleave at yield points. This is the whole strength (no locks, no data races on shared state within a tick) and the whole weakness (one callback that never yields freezes everything), which the next lesson makes concrete.

SchedulerQueue typePhase / timingPriority
process.nextTick(fn)MicrotaskAfter current callback, before promisesHighest
Promise.then / awaitMicrotaskAfter current callback, after nextTickHigh
setTimeout(fn, 0)MacrotaskTimers phase, next eligible tickNormal
I/O callbackMacrotaskPoll phaseNormal
setImmediate(fn)MacrotaskCheck phase, right after pollNormal
Quiz

Inside an I/O callback you schedule `setTimeout(a, 0)`, `setImmediate(b)`, and `Promise.resolve().then(c)`. In what order do they run?

Quiz

Why can a recursive `process.nextTick` (or a runaway promise chain) stall I/O even though no synchronous loop is running?

Order the steps

Order one tick of the event loop across its main phases:

  1. 1 Timers phase: run elapsed setTimeout/setInterval callbacks
  2. 2 Drain microtasks (nextTick, then promises)
  3. 3 Poll phase: run ready I/O callbacks (or wait on epoll/kqueue)
  4. 4 Check phase: run setImmediate callbacks
  5. 5 Close phase: run close-event callbacks
Recall before you leave
  1. 01
    What are the main phases of the event loop and what does each do?
  2. 02
    What is the difference between macrotasks and microtasks, and what is the draining rule?
  3. 03
    Why is event-loop concurrency described as cooperative rather than parallel, and what follows from that?
Recap

The event loop is a strict, ordered machine, not a vague ‘runs async later’ box. One thread cycles through fixed phases — timers, poll, check, close — and within each phase runs queued callbacks one at a time to completion. Poll is the center of gravity: it runs ready I/O callbacks and, when idle, blocks on epoll/kqueue. Crosscutting the phases are two higher-priority microtask queues, process.nextTick then promise reactions, and the defining rule is that the loop drains microtasks to empty after every callback before advancing — which is exactly why a resolved promise beats setTimeout(0), and why setImmediate (check phase) and setTimeout(0) (timers phase) are not interchangeable. Because callbacks run to completion without preemption, the concurrency is cooperative: requests interleave only where they yield, giving lock-free safety within a tick but making the loop hostage to any callback that refuses to yield. That hostage scenario — what actually blocks the loop and how to see it — is the next lesson.

Connected lessons
appears again in185
Continue the climb ↑What blocks the loop: CPU work and sync calls
shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.