NodeJSThe Event Loop in Depth

The Event Loop in Depth

The event loop is the heartbeat of Node. It's the mechanism that lets one thread run your synchronous code, hand off I/O, and then pick up each completed operation's callback at the right moment. Almost every confusing "why did this run in that order?" question in Node is answered by understanding the loop. Let's build it from the ground up.

The pieces involved

Piece

Role

Call stack

Where the currently-running function executes (one at a time)

Node APIs / libuv

Perform I/O, timers, etc. off the main thread

Callback queues

Hold callbacks whose operations have finished

Microtask queue

Holds promise reactions & queueMicrotask (high priority)

Event loop

Moves ready callbacks onto the empty call stack

The one rule that explains everything
A callback runs only when the call stack is empty
The event loop can push the next callback onto the stack *only* when the currently-executing JavaScript has fully returned — the stack must be empty. This is "run-to-completion": each task runs start to finish without interruption. It's why a long synchronous function delays *every* pending timer, promise, and I/O callback, and why Node code is free of the data races that plague multi-threaded languages.
The big picture

How a callback travels back to your code

Text
   ┌─────────────┐   run your code, one frame at a time
   │ CALL STACK  │ ◄───────────────────────────────┐
   └─────┬───────┘                                  │
         │ async call (readFile, setTimeout…)       │ loop pushes a
         ▼                                          │ callback when
   ┌─────────────┐   OS/libuv do the slow work      │ the stack is EMPTY
   │ Node / libuv│ ─────────┐                        │
   └─────────────┘          ▼ when done             │
                      ┌──────────────┐               │
                      │ callback     │───────────────┘
                      │ queues       │
                      └──────────────┘
         ▲ EVENT LOOP drains microtasks first, then a queued callback
A canonical ordering example

This snippet trips up nearly everyone the first time. Walk through it with the one rule in mind:

JS
console.log('1: sync start')

setTimeout(() => console.log('2: timeout'), 0)

Promise.resolve().then(() => console.log('3: promise'))

console.log('4: sync end')
1: sync start
4: sync end
3: promise
2: timeout

Why this order? Both synchronous logs run first (they're on the stack now). Then, before the loop touches any timer, it drains the microtask queue — so the promise callback (3) runs. Only then does the loop enter its timer phase and run the setTimeout callback (2). Microtasks always beat macrotasks.

Microtasks vs macrotasks — the key split
Promise reactions and `queueMicrotask` go on the **microtask** queue, drained *completely* after every single task and between loop phases. `setTimeout`, `setImmediate`, and I/O callbacks are **macrotasks**, run one-per-phase. This priority is the single most important ordering fact in Node — it has its own page: [Microtasks vs Macrotasks](/nodejs/microtasks-macrotasks).
The loop runs in phases

Each turn ("tick") of the loop isn't one undifferentiated queue — it's a fixed sequence of phases, each with its own callback type. Timers run in one phase, I/O callbacks in another, setImmediate in another:

One iteration of the loop (simplified)

Text
   ┌─► timers          (setTimeout / setInterval callbacks)
   │   pending I/O      (some system callbacks)
   │   poll             (retrieve new I/O; run I/O callbacks)
   │   check            (setImmediate callbacks)
   └── close            (e.g. 'close' events)
       ↑ microtasks drained between EACH phase

Each phase is detailed in Event Loop Phases.

Why blocking is catastrophic here
A long sync task stalls the whole loop
Since the loop can't advance until the stack empties, a single `while` loop that runs for two seconds means *two seconds* with no timers firing, no requests accepted, no promises resolving. The whole server appears frozen. Keep each task short; offload heavy CPU work to [worker threads](/nodejs/blocking-vs-nonblocking) so the loop keeps spinning.
The takeaways
  • One thread, run-to-completion — each task finishes before the next starts; no preemption, no races.

  • Callbacks wait for an empty stack — they never interrupt running code.

  • Microtasks (promises) outrank macrotasks (timers, I/O) — and drain fully between them.

  • The loop has ordered phases — which is why setTimeout(…, 0) and setImmediate can differ.

  • Keep tasks short — the loop is shared; hog it and everything stalls.

Next
Zoom into each stage of a single tick: [Event Loop Phases](/nodejs/event-loop-phases).