Explorer
Node.js

Chapter 4: The Node.js Event Loop — Phases, Microtasks, and Execution Order

Chapter 4: The Node.js Event Loop — Phases, Microtasks, and Execution Order

In the previous chapter, we established that Node.js executes JavaScript on a single thread using the Google V8 engine, while delegating asynchronous system operations to libuv and the operating system.

This brings us to the operational core of Node.js:

How does Node.js coordinate when asynchronous operations complete and determine the exact order in which their callbacks execute?

The mechanism that governs this coordination is the libuv Event Loop, coupled with Node.js's internal Microtask Queues.

Understanding the event loop with architectural rigor is critical. Misunderstanding callback scheduling, timer resolution, and microtask priority leads to insidious production defects: unexpected execution races, delayed request handling, unhandled promise rejections, and event loop starvation.


1. The Core Purpose of the Event Loop

At any given instant, your application's JavaScript code executes inside a single Call Stack managed by V8.

TEXT
┌────────────────────────────────────────────────────────┐
│                   CALL STACK (V8)                      │
│            Executes pure JavaScript code               │
│               Only 1 frame runs at a time              │
└──────────────────────────┬─────────────────────────────┘
                           │ All synchronous code
                           │ must drain to 0
                           ▼
┌────────────────────────────────────────────────────────┐
│                 LIBUV EVENT LOOP                       │
│        Coordinates asynchronous I/O completion,        │
│       timers, and schedules callbacks into the stack   │
└────────────────────────────────────────────────────────┘

The fundamental law of the Node.js runtime is:

The Call Stack always runs to completion. No queued callback, timer, or I/O event can interrupt currently executing synchronous JavaScript.

When the Call Stack becomes completely empty, the Event Loop coordinates with V8 to deliver the next eligible callback for execution.


2. The Complete Anatomy of the Event Loop: The 6 Phases

Internally, libuv organizes the event loop into six distinct phases. Each phase maintains a FIFO (First-In, First-Out) queue of callbacks specific to that phase:

TEXT
   ┌──────────────────────────────────────────────┐
┌─>│ 1. Timers                                    │
│  │    setTimeout(), setInterval() callbacks     │
│  └──────────────────────┬───────────────────────┘
│  ┌──────────────────────┴───────────────────────┐
│  │ 2. Pending Callbacks                         │
│  │    Deferred I/O callbacks from previous loop │
│  └──────────────────────┬───────────────────────┘
│  ┌──────────────────────┴───────────────────────┐
│  │ 3. Idle, Prepare                             │
│  │    Internal libuv house-keeping only         │
│  └──────────────────────┬───────────────────────┘
│  ┌──────────────────────┴───────────────────────┐
│  │ 4. Poll                                      │ ◄── Incoming I/O:
│  │    Retrieve new I/O events; execute I/O      │     sockets, files,
│  │    callbacks; block here when appropriate    │     HTTP requests
│  └──────────────────────┬───────────────────────┘
│  ┌──────────────────────┴───────────────────────┐
│  │ 5. Check                                     │
│  │    setImmediate() callbacks                  │
│  └──────────────────────┬───────────────────────┘
│  ┌──────────────────────┴───────────────────────┐
└──┤ 6. Close Callbacks                           │
   │    socket.on('close', ...), handle cleanup   │
   └──────────────────────────────────────────────┘

When libuv enters a given phase, it executes callbacks from that phase's queue until the queue is exhausted or the system-dependent maximum number of callbacks has been reached.

1. Timers Phase

This phase executes callbacks scheduled by setTimeout() and setInterval().

  • The event loop checks its internal min-heap of active timers.
  • If a timer's threshold (delay) has expired relative to the current loop timestamp, its callback is executed.
  • If no timers are expired, the loop moves immediately to the next phase.

2. Pending Callbacks Phase

This phase executes I/O callbacks that were deferred from the previous loop iteration.

  • For example, if a TCP socket encounters a network error like ECONNREFUSED while attempting to connect, some operating systems report the error asynchronously. libuv defers reporting this error until the Pending Callbacks phase of the subsequent iteration.

3. Idle, Prepare Phase

This phase is used exclusively by libuv for internal subsystem book-keeping. Application code cannot schedule callbacks into this phase directly.

4. Poll Phase (The Engine Room)

The Poll phase has two primary responsibilities:

  1. Calculating how long it should block and poll for new operating system I/O events.
  2. Processing events in the poll queue (incoming network packets, database responses, file read completions).

When the event loop enters the Poll phase:

  • If the poll queue is not empty, libuv iterates through the queue and executes the I/O callbacks synchronously until the queue is drained or the maximum limit is reached.
  • If the poll queue is empty:
    • If callbacks are queued in the Check phase (setImmediate), the Poll phase ends immediately and progresses to Check.
    • If timers have expired in the Timers phase, the loop wraps around to process them.
    • If no setImmediate() callbacks are waiting and no timers are ready, Node.js blocks and waits for incoming OS I/O events (connections, data) up to the timeout of the nearest scheduled timer.

5. Check Phase

This phase exists specifically to allow Node.js to execute callbacks immediately after the Poll phase has completed.

  • Any callback scheduled via setImmediate() is placed directly into the Check phase queue.
  • This ensures that setImmediate() callbacks run after the Poll phase finishes, before returning to the start of the loop.

6. Close Callbacks Phase

If a handle or socket is closed abruptly or via destroy(), libuv emits the 'close' event in this phase:

JAVASCRIPT
socket.on('close', () => {
  console.log('Socket fully closed and resources reclaimed');
});

💡 Visualizer: Test this in the Interactive Event Loop Lab (Scenario 1: Complete Cycle).

This guarantees dedicated cleanup of operating system descriptors.


3. The Microtask Queues: The Intermediate Layer

One of the most critical aspects of the Node.js runtime is that process.nextTick() and Promise callbacks are not part of the libuv event loop phases.

Instead, they live in two prioritized Microtask Queues managed by Node.js and V8:

TEXT
┌────────────────────────────────────────────────────────┐
│                   CURRENT CALL STACK                   │
│               (Any Phase Callback or Script)           │
└──────────────────────────┬─────────────────────────────┘
                           │ Stack Drains to Empty
                           ▼
┌────────────────────────────────────────────────────────┐
│                 1. nextTickQueue                       │
│             Callbacks from process.nextTick()          │
└──────────────────────────┬─────────────────────────────┘
                           │ Drains completely
                           ▼
┌────────────────────────────────────────────────────────┐
│               2. Promise Microtask Queue               │
│        Promise.then(), catch, finally, queueMicrotask  │
└──────────────────────────┬─────────────────────────────┘
                           │ Drains completely
                           ▼
┌────────────────────────────────────────────────────────┐
│               RESUME LIBUV EVENT LOOP                  │
│       Proceeds to the next callback or phase           │
└────────────────────────────────────────────────────────┘

The Two Tiers of Microtasks

  1. process.nextTick Queue (Highest Priority):
    • Specific to Node.js.
    • Runs immediately when the current JavaScript Call Stack finishes, before any other asynchronous task or Promise microtask.
  2. Promise Microtask Queue:
    • Standard ECMAScript specification.
    • Includes callbacks registered via Promise.prototype.then(), .catch(), .finally(), and queueMicrotask().
    • Runs immediately after the nextTickQueue has completely emptied.

Modern Microtask Drainage (Node.js 11+)

The HTML5 Alignment: In modern Node.js (v11.0.0 and later, aligned with browser standards), microtask queues drain after every single callback executes, not just between event loop phases.

Historically in Node 10 and earlier, Node drained microtasks only at phase boundaries. In modern Node.js, whether exiting top-level script execution, a single timer callback, an individual I/O callback, or a setImmediate callback: as soon as the V8 Call Stack reaches depth 0, Node.js drains all pending process.nextTick callbacks, followed by all Promise microtasks, before the event loop invokes the next callback.


4. Comprehensive Deep Dive: 6 Classic Interview Scenarios

Let's deconstruct the 6 classic asynchronous execution puzzles every Node.js developer should master.


Scenario 1: The Top-Level Race (setTimeout(0) vs setImmediate)

Consider this classic problem:

JAVASCRIPT
// index.js
setTimeout(() => {
  console.log('setTimeout');
}, 0);

setImmediate(() => {
  console.log('setImmediate');
});

💡 Visualizer: Test this in the Interactive Event Loop Lab (Scenario 2: Top-Level Race).

The Question: Which executes first?

The Answer:

The execution order is non-deterministic. It is bound to process startup performance, timer clamping, and operating system CPU scheduling.

TEXT
// Run 1:
setTimeout
setImmediate

// Run 2:
setImmediate
setTimeout

The Architectural Reason:

  1. In Node.js, setTimeout(fn, 0) is internally normalized to setTimeout(fn, 1) because Node clamps 0ms and negative timeouts to a minimum of 1 millisecond.
  2. The main script finishes synchronously, registers both callbacks, and boots the libuv event loop.
  3. If initializing the event loop takes less than 1ms, the timer min-heap check reveals that the 1ms threshold has not yet elapsed. Libuv skips the Timers phase, proceeds through Poll, arrives at Check, and executes setImmediate first.
  4. If entering the loop takes more than 1ms (due to system load or compilation overhead), the timer has elapsed, and setTimeout executes first.

Scenario 2: The Guaranteed Deterministic I/O Context

Now, take those identical functions and wrap them inside any I/O callback:

JAVASCRIPT
import fs from 'node:fs';

fs.readFile('package.json', () => {
  setTimeout(() => {
    console.log('setTimeout');
  }, 0);

  setImmediate(() => {
    console.log('setImmediate');
  });
});

💡 Visualizer: Test this in the Interactive Event Loop Lab (Scenario 3: I/O Order).

The Question: Which executes first?

The Answer:

setImmediate is guaranteed to execute first, 100% of the time.

TEXT
setImmediate
setTimeout

The Architectural Reason:

  • The fs.readFile callback is currently running inside the Poll phase.
  • When the I/O callback completes, the Call Stack is empty and the Poll phase finishes.
  • The very next phase in the clockwise progression of the event loop is the Check phase.
  • The event loop steps directly into the Check phase and executes the setImmediate callback.
  • The setTimeout callback must wait for the loop to pass through Close Callbacks and wrap around to the Timers phase on the next iteration.

Scenario 3: Async/Await and Promise Constructor Internals

One of the most common misconceptions is assuming async functions are completely asynchronous from start to finish.

JAVASCRIPT
async function async1() {
  console.log('1: async1 start');
  await async2();
  console.log('2: async1 end');
}

async function async2() {
  console.log('3: async2 body');
}

console.log('4: Script start');

setTimeout(() => {
  console.log('5: setTimeout');
}, 0);

async1();

new Promise((resolve) => {
  console.log('6: Promise constructor');
  resolve();
}).then(() => {
  console.log('7: Promise.then');
});

console.log('8: Script end');

💡 Visualizer: Test this in the Interactive Event Loop Lab (Scenario 5: Async/Await Internals).

The Question: What is the exact execution trace?

The Execution Analysis:

  1. 4: Script start prints synchronously.
  2. setTimeout registers with the libuv timer min-heap.
  3. async1() is invoked synchronously:
    • Prints 1: async1 start.
    • Calls async2().
  4. async2() runs synchronously:
    • Prints 3: async2 body.
    • Returns a resolved Promise.
  5. The await in async1 pauses execution and schedules the remainder of async1 (2: async1 end) into the Promise Microtask Queue.
  6. The new Promise constructor runs synchronously:
    • Prints 6: Promise constructor.
    • resolve() enqueues 7: Promise.then into the Promise Microtask Queue.
  7. 8: Script end prints synchronously.
  8. Call Stack is empty! Microtasks drain in FIFO order:
    • Resumes async1 -> prints 2: async1 end.
    • Executes .then() -> prints 7: Promise.then.
  9. Event Loop takes over: Timers phase fires -> prints 5: setTimeout.

The Exact Output:

TEXT
4: Script start
1: async1 start
3: async2 body
6: Promise constructor
8: Script end
2: async1 end
7: Promise.then
5: setTimeout

Scenario 4: Deep Microtask Interleaving (process.nextTick vs Promise)

What happens when a Promise schedules a nextTick, and a nextTick schedules a Promise?

JAVASCRIPT
Promise.resolve().then(() => {
  console.log('1: Promise A');
  process.nextTick(() => {
    console.log('2: nextTick scheduled inside Promise A');
  });
  Promise.resolve().then(() => {
    console.log('3: Promise B (nested)');
  });
});

process.nextTick(() => {
  console.log('4: nextTick A');
  Promise.resolve().then(() => {
    console.log('5: Promise scheduled inside nextTick A');
  });
});

console.log('6: Script start');

💡 Visualizer: Test this in the Interactive Event Loop Lab (Scenario 6: Microtask Interleaving).

The Question: What is the exact output?

The Step-by-Step Drainage:

  1. 6: Script start prints synchronously on the Call Stack.
  2. Synchronous Call Stack empties to 0. Node invokes processTicksAndRejections().
  3. nextTickQueue Drains First:
    • Node executes nextTick A -> prints 4: nextTick A. Inside, resolving a Promise schedules 5: Promise scheduled inside nextTick A into V8's MicrotaskQueue.
  4. nextTickQueue is now empty.
  5. V8 MicrotaskQueue Drains: Node delegates to V8 (runMicrotasks() / v8::MicrotaskQueue::PerformCheckpoint()):
    • At this point, V8's microtask queue contains: [Promise A, Promise 5].
    • V8 executes Promise A -> prints 1: Promise A. Inside it, a callback is pushed to Node's nextTickQueue (2: nextTick), and a chained Promise (3: Promise B) is appended to V8's MicrotaskQueue.
    • V8 drains its own microtask queue completely before yielding control back to Node:
      • Next in V8 queue: executes Promise 5 -> prints 5: Promise scheduled inside nextTick A.
      • Next in V8 queue: executes Promise B -> prints 3: Promise B (nested).
  6. V8's MicrotaskQueue is now empty, so control returns to Node's outer processTicksAndRejections() loop.
  7. Node inspects its nextTickQueue, finds the deferred nextTick callback, and drains it -> prints 2: nextTick scheduled inside Promise A.

The Exact Output:

TEXT
6: Script start
4: nextTick A
1: Promise A
5: Promise scheduled inside nextTick A
3: Promise B (nested)
2: nextTick scheduled inside Promise A

Scenario 5: Multiple Expired Timers and Microtask Drainage (Node 10 vs Node 11+)

This question tests understanding of modern Node.js runtime behavior versus outdated documentation.

JAVASCRIPT
setTimeout(() => {
  console.log('Timer 1');
  Promise.resolve().then(() => console.log('Promise inside Timer 1'));
}, 0);

setTimeout(() => {
  console.log('Timer 2');
  Promise.resolve().then(() => console.log('Promise inside Timer 2'));
}, 0);

💡 Visualizer: Test this in the Interactive Event Loop Lab (Scenario 4: Node 11+ Microtask Drain).

The Question: If both timers expire at the exact same millisecond, what is the output in modern Node.js?

The Evolution:

  • Node 10 and older (Historical): Microtasks drained only at phase boundaries. Both timers ran in the Timers phase, followed by both microtasks:
    TEXT
    Timer 1
    Timer 2
    Promise inside Timer 1
    Promise inside Timer 2
    
  • Node 11+ (Modern Node.js / HTML5 Specification): Microtasks drain immediately after each individual callback finishes:
    TEXT
    Timer 1
    Promise inside Timer 1
    Timer 2
    Promise inside Timer 2
    

Why it matters:

Node 11 aligned Node.js with the browser's HTML5 event loop specification. If an expired timer callback resolves a Promise, that Promise's microtasks run before the event loop moves to the second expired timer callback.


Scenario 6: The Safe EventEmitter Pattern

Why does Node.js core use process.nextTick() in constructors instead of emitting synchronously or using setImmediate()?

JAVASCRIPT
import { EventEmitter } from 'node:events';

class NetworkStream extends EventEmitter {
  constructor() {
    super();
    // Problem: Emitting synchronously here will FAIL:
    // this.emit('ready', { connected: true });

    // Correct Architectural Pattern:
    process.nextTick(() => {
      this.emit('ready', { connected: true });
    });
  }
}

// Consumer code:
const stream = new NetworkStream();

stream.on('ready', (data) => {
  console.log('Stream ready event caught:', data);
});

💡 Visualizer: Test this in the Interactive Event Loop Lab (Scenario 7: Safe EventEmitter).

The Architectural Reason:

  1. If the constructor emitted 'ready' synchronously, the event would fire before the consumer could ever attach .on('ready', ...) on line 18. The event would be missed.
  2. If the constructor used setImmediate(), the event would not fire until the entire event loop iterated to the Check phase. Any other synchronous initialization or fast microtask would execute first, delaying the stream unnecessarily.
  3. By using process.nextTick(), the event is guaranteed to fire immediately after the current synchronous script finishes (giving the caller time to attach listeners), but before any other event loop phase or I/O operation begins.

5. Production Anti-Patterns & Event Loop Diagnostics

1. The Microtask Starvation Trap

Because Node.js prioritizes draining microtasks before yielding back to the libuv event loop, recursive microtasks will completely freeze your server:

JAVASCRIPT
function freezeServer() {
  process.nextTick(freezeServer);
}

freezeServer();

// The server below will NEVER accept a connection:
import http from 'node:http';
const server = http.createServer((req, res) => res.end('ok'));
server.listen(3000);

💡 Visualizer: Test this in the Interactive Event Loop Lab (Scenario 8: nextTick Starvation).

Production Remedy:

Never schedule recursive microtasks. If you need to break up long-running tasks, use setImmediate(). Unlike process.nextTick(), setImmediate() runs once per loop iteration, allowing libuv to poll for I/O and process network requests between chunks:

JAVASCRIPT
function processLargeDataset(items, index = 0) {
  if (index >= items.length) return;

  // Process a chunk of 500 items synchronously
  const nextIndex = Math.min(index + 500, items.length);
  for (let i = index; i < nextIndex; i++) {
    compute(items[i]);
  }

  // Yield control back to libuv to allow I/O handling
  setImmediate(() => processLargeDataset(items, nextIndex));
}

💡 Visualizer: Test this in the Interactive Event Loop Lab (Scenario 8: nextTick Starvation).

2. Measuring Event Loop Lag in Production

When Node.js handles heavy traffic, how do you know if the event loop is lagging?

You measure Event Loop Delay using Node's built-in perf_hooks module:

JAVASCRIPT
import { monitorEventLoopDelay } from 'node:perf_hooks';

const histogram = monitorEventLoopDelay({ resolution: 20 });
histogram.enable();

setInterval(() => {
  console.log({
    minMs: (histogram.min / 1e6).toFixed(2),
    maxMs: (histogram.max / 1e6).toFixed(2),
    meanMs: (histogram.mean / 1e6).toFixed(2),
    p99Ms: (histogram.percentile(99) / 1e6).toFixed(2),
  });
  histogram.reset();
}, 5000);

💡 Visualizer: Test this in the Interactive Event Loop Lab (Scenario 9: Thread Pool vs OS Async).

If p99Ms climbs above 20–50ms in production, it indicates synchronous CPU work is blocking the Call Stack, preventing the event loop from servicing incoming traffic.


6. Master Reference Table & Decision Matrix

Construct Queue Type Execution Timing Preempted By Best Used For
Synchronous JS Call Stack (V8) Immediate Nothing (Runs to completion) Normal business logic
process.nextTick() nextTickQueue Immediately after current stack drains Call Stack Constructor event emission, urgent state cleanup
Promise.then() Microtask Queue Immediately after nextTickQueue drains process.nextTick Standard async/await, Promise pipelines
setTimeout() Libuv Timers Phase After delay threshold, during Timers phase Stack & all microtasks Scheduled delays, heartbeat timers
setImmediate() Libuv Check Phase Directly following the Poll phase Stack & all microtasks Yielding CPU back to I/O, chunking heavy workloads
fs.readFile() Libuv Poll Phase When OS signals I/O readiness, in Poll phase Stack & all microtasks Non-blocking filesystem operations

7. The 5 Golden Rules of Node.js Asynchronous Execution

  1. Synchronous code always wins: No queued callback or microtask can execute while the V8 Call Stack is occupied.
  2. nextTick preempts Promises: process.nextTick() callbacks always execute before Promise microtasks.
  3. Microtasks drain per-callback (Node 11+): Microtasks execute immediately when the stack clears after each callback, not just at phase boundaries.
  4. Inside I/O, setImmediate always beats setTimeout(0): In any network or file callback, the Poll phase transitions directly to Check.
  5. Protect the Event Loop: Never perform recursive microtasks or CPU-intensive synchronous loops on the main thread; use setImmediate() or worker threads to distribute load across ticks.

Finished this lesson?

Mark this chapter complete to update your learning streak and unlock the next lesson.