DEV Community

Cover image for The Node.js Event Loop Under the Hood: Timers, I/O Polling, and Microtasks
DEVANSHU PATIL
DEVANSHU PATIL

Posted on AI-assisted

The Node.js Event Loop Under the Hood: Timers, I/O Polling, and Microtasks

The Node.js Event Loop Under the Hood: Timers, I/O Polling, and Microtasks

title: "The Node.js Event Loop Under the Hood: Timers, I/O Polling, and Microtasks"
published: true
published_at: "2026-11-08T09:00:00+05:30"
description: "An in-depth technical examination of the Node.js event loop architecture powered by libuv. Explore the distinct phases, timer operations, poll phase I/O mechanics, setImmediate, and the exact priority queue differences between process.nextTick and Promise microtasks."
tags: [nodejs, javascript, backend, programming]
ai_disclosure_level: some_ai

Introduction

Node.js is widely recognized for its non-blocking, asynchronous I/O model. At the heart of this architecture lies the event loop, which handles concurrency and offloads operations to the underlying operating system whenever possible. While JavaScript is single-threaded, Node.js leverages libuv, a multi-platform C library that provides support for asynchronous I/O based on event loops.

Understanding the precise mechanics of the event loop is crucial for writing performant backend applications, avoiding race conditions, and debugging subtle timing bugs.

Anatomy of the Libuv Event Loop

The event loop is an infinite loop that orchestrates asynchronous tasks by cycling through distinct phases. Each phase maintains a FIFO (First In, First Out) queue of callbacks to execute. When the event loop enters a given phase, it performs operations specific to that phase until the queue is exhausted or the maximum number of callbacks has been executed, at which point it transitions to the next phase.

   +----------------------------------+
   |             timers               | <--- setTimeout(), setInterval()
   +----------------------------------+
   |         pending callbacks        | <--- I/O system errors (e.g., ECONNREFUSED)
   +----------------------------------+
   |           idle, prepare          | <--- Internal use only
   +----------------------------------+
   |             poll                 | <--- Retrieve new I/O events; execute I/O callbacks
   +----------------------------------+
   |            check                 | <--- setImmediate()
   +----------------------------------+
   |          close callbacks         | <--- socket.on('close', ...)
   +----------------------------------+
Enter fullscreen mode Exit fullscreen mode

1. Timers Phase

This phase executes callbacks scheduled by setTimeout() and setInterval(). A timer specifies a threshold after which a callback may be executed, rather than an exact time. Operating system scheduling or other callbacks can delay timer execution.

2. Pending Callbacks Phase

This phase executes I/O callbacks deferred to the next loop iteration. This includes certain system-level errors, such as a TCP socket receiving ECONNREFUSED when trying to connect.

3. Idle, Prepare Phase

These phases are used internally by libuv for housekeeping and maintenance operations. They are not directly exposed to user-written JavaScript code.

4. Poll Phase

The poll phase is the most critical and complex part of the event loop. It has two primary functions:

  1. Calculating how long it should block and poll for I/O, then
  2. Processing events in the poll queue.

When the event loop enters the poll phase and there are no timers scheduled, one of two things happens:

  • If the poll queue is not empty, the event loop iterates through its queue synchronously until the queue is exhausted or system-dependent limits are reached.
  • If the poll queue is empty:
    • If scripts have been scheduled by setImmediate(), the event loop will exit the poll phase and enter the check phase to execute those scripts.
    • If there are no scripts scheduled by setImmediate(), the event loop will wait for callbacks to be added to the queue, then execute them immediately.

5. Check Phase

This phase allows callbacks to be executed immediately after the poll phase finishes. If the poll phase becomes idle and scripts have been queued with setImmediate(), the event loop proceeds to the check phase rather than waiting.

6. Close Callbacks Phase

If a socket or handle is closed abruptly (e.g., socket.destroy()), the 'close' event is emitted in this phase.

Microtasks: process.nextTick vs Promises

Between every phase transition—and even between individual callback executions within a phase—the event loop checks for microtasks. Node.js features two distinct microtask queues:

  1. process.nextTick() queue
  2. Promise microtask queue (native JavaScript promises, async/await)

The Priority Rule

The process.nextTick() queue holds higher priority than the Promise microtask queue. When the current operation completes, Node.js will process all tasks in the process.nextTick queue first, followed by all tasks in the Promise microtask queue, before moving on to the next phase of the event loop.

console.log('Start');

setTimeout(() => {
  console.log('Timer 1');
}, 0);

Promise.resolve().then(() => {
  console.log('Promise 1');
});

process.nextTick(() => {
  console.log('NextTick 1');
});

console.log('End');
Enter fullscreen mode Exit fullscreen mode

Output Order:

Start
End
NextTick 1
Promise 1
Timer 1
Enter fullscreen mode Exit fullscreen mode

Explanation of Execution:

  1. Synchronous code runs first: prints Start and End.
  2. The call stack is now empty. Node.js checks the microtask queues.
  3. process.nextTick() runs first, printing NextTick 1.
  4. The Promise microtask queue runs next, printing Promise 1.
  5. The event loop starts its first official iteration, entering the timers phase, and executes the setTimeout callback, printing Timer 1.

Pitfalls: I/O Cycles and Execution Order

Consider the interaction between setTimeout() and setImmediate() when executed outside of an I/O cycle:

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

setImmediate(() => {
  console.log('setImmediate');
});
Enter fullscreen mode Exit fullscreen mode

Running this snippet multiple times can yield non-deterministic results. The order depends heavily on system performance and process load:

  • If the script is started, the timer threshold (0ms) is registered. However, setting up the timer takes a fraction of a millisecond. If system performance is slow or loaded, the timer may not be ready when the event loop starts, leading setImmediate to run first.
  • Conversely, if the event loop starts quickly and the timer is already registered, the timers phase executes first.

Deterministic Behavior Inside I/O

When setTimeout and setImmediate are called inside an I/O cycle callback, the execution order is always deterministic:

const fs = require('fs');

fs.readFile(__filename, () => {
  setTimeout(() => {
    console.log('setTimeout inside I/O');
  }, 0);

  setImmediate(() => {
    console.log('setImmediate inside I/O');
  });
});
Enter fullscreen mode Exit fullscreen mode

Output Order (Guaranteed):

setImmediate inside I/O
setTimeout inside I/O
Enter fullscreen mode Exit fullscreen mode

Why?

  1. The fs.readFile callback executes in the poll phase.
  2. Inside this callback, setTimeout registers a timer, and setImmediate queues a check callback.
  3. When the I/O callback finishes, the poll phase checks for scheduled setImmediate callbacks.
  4. Finding setImmediate, the event loop bypasses further polling, skips to the check phase, and executes setImmediate.
  5. In the next iteration of the event loop, the timers phase is reached, and setTimeout executes.

Conclusion

Mastering the Node.js event loop requires understanding libuv's phase transition logic, the distinct roles of the poll and check phases, and the strict priority of process.nextTick over Promise microtasks. By keeping these execution rules in mind, engineers can design predictable, non-blocking backend applications free of race conditions and hidden latency bottlenecks.

Top comments (0)