Skip to content
⚠️ This article was written in 2018. Some content may be outdated.

Deep Dive into the Node.js Event Loop

A classic Node.js interview topic — but many people only memorize the conclusions without understanding the underlying mechanism. This article explains the event loop through the actual execution order of real code.

Why Node.js Is Single-Threaded but Non-Blocking ​

Node.js's main thread is single-threaded, but I/O operations (file reads, network requests) are delegated to the OS for asynchronous processing.

Main thread → initiates I/O request → continues executing subsequent code
                ↓ (delegated to the OS)
            OS completes I/O → puts callback into the event queue
                ↓
When main thread is idle → takes callbacks from queue and executes them

This is the fundamental idea behind the event loop.

The Six Phases of the Event Loop ​

Node.js's event loop is implemented by libuv and consists of six phases:

   ┌───────────────────────────┐
┌─>│           timers          │  callbacks for setTimeout / setInterval
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │  deferred I/O callbacks from the previous iteration
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │  internal use only
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │           poll            │  retrieve new I/O events (main phase)
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │           check           │  callbacks for setImmediate
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
└──┤      close callbacks      │  e.g. socket.on('close', ...)
   └───────────────────────────┘

microtask vs macrotask ​

This is the most confusing part.

macrotask (macro task):

  • setTimeout
  • setInterval
  • setImmediate
  • I/O callbacks

microtask (micro task):

  • Promise.then/.catch/.finally
  • process.nextTick (highest priority microtask)
  • queueMicrotask

Execution rule: After each macro task completes, all microtasks are immediately drained, then the next macro task runs.

Classic Problem Walkthrough ​

javascript
console.log("1");

setTimeout(() => console.log("2"), 0);

Promise.resolve()
  .then(() => console.log("3"))
  .then(() => console.log("4"));

process.nextTick(() => console.log("5"));

console.log("6");

Execution order: 1 6 5 3 4 2

Analysis:

  1. 1 — synchronous code
  2. setTimeout goes to the macrotask queue
  3. Promise.then goes to the microtask queue
  4. process.nextTick goes to the nextTick queue (highest priority microtask)
  5. 6 — synchronous code
  6. Drain microtasks: nextTick first (5), then Promise.then (3, 4)
  7. Next macrotask: setTimeout (2)

setImmediate vs setTimeout(fn, 0) ​

javascript
setTimeout(() => console.log("setTimeout"), 0);
setImmediate(() => console.log("setImmediate"));

The result is non-deterministic! When executed in the main module, the order depends on system timer precision.

But inside an I/O callback, setImmediate always runs before setTimeout:

javascript
const fs = require("fs");

fs.readFile("./file.txt", () => {
  setTimeout(() => console.log("setTimeout"), 0);
  setImmediate(() => console.log("setImmediate"));
});

// setImmediate always prints first

Reason: I/O callbacks run in the poll phase. After poll completes, the loop moves directly to the check phase (setImmediate) before looping back to the timers phase (setTimeout).

The process.nextTick Pitfall ​

process.nextTick callbacks execute before each phase transition — the highest priority. Abusing it can starve I/O:

javascript
// ❌ Recursive nextTick — I/O will never execute
function loopNextTick() {
  process.nextTick(loopNextTick);
}
loopNextTick();

Practical Applications ​

javascript
// Turn synchronous code into async (gives callers a chance to attach event listeners)
class EventEmitter {
  emit(event, data) {
    process.nextTick(() => {
      this.listeners[event]?.forEach((fn) => fn(data));
    });
  }
}

// Ensure async API consistency
function readData(callback) {
  if (this.cache) {
    process.nextTick(() => callback(null, this.cache)); // keep it async
    return;
  }
  fs.readFile("./data", callback);
}

Summary ​

  • The event loop is Node.js's core mechanism for handling concurrency
  • Microtasks (Promise, nextTick) execute immediately after each macrotask
  • process.nextTick has higher priority than Promise.then
  • Inside I/O, setImmediate runs before setTimeout
  • Avoid infinite recursion in nextTick/Promise — it blocks I/O

MIT Licensed