โ† Back to Blog

Demystifying the Node.js Event Loop (With Visuals)

Node.js is famous for being fast, scalable, and capable of handling thousands of concurrent connections on a single thread. But how is this possible? The magic lies in the Event Loop. In this guide, we'll peel back the layers and understand exactly how Node.js manages asynchronous operations under the hood.

The Single-Threaded Myth

You've likely heard that Node.js is "single-threaded." While this is true for the main execution thread (where your JavaScript code runs), Node.js uses a C++ library called libuv under the hood. Libuv provides a thread pool (typically 4 threads by default) to handle heavy, blocking operations like reading files, cryptographic operations, or database queries. This keeps your main thread free to handle fast incoming requests.

๐Ÿ’ก Key Concept: The Event Loop acts like a coordinator. It coordinates the execution of JavaScript callbacks on the main thread and delegates blocking tasks to libuv's thread pool or the operating system.

The Phases of the Event Loop

The event loop is a continuous loop that executes a series of phases in order. Let's look at the structure:

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ incoming โ”‚ โ”‚ connections โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ–ผ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ–บโ”‚ Timers โ”‚ (setTimeout, setInterval) โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ โ–ผ โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ โ”‚ Pending Callbacks โ”‚ (Deferred system callbacks) โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ โ–ผ โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ โ”‚ Idle, Prepare โ”‚ (Internal library operations) โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ โ–ผ โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ โ”‚ Poll โ”‚ (Retrieve new I/O events) โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ โ–ผ โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ โ”‚ Check โ”‚ (setImmediate callbacks) โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ โ–ผ โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ””โ”€โ”€โ”€โ”€โ”€โ”ค Close Callbacks โ”‚ (socket.on('close'), etc.) โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Each phase has a FIFO (first-in-first-out) queue of callbacks to execute. When the event loop enters a given phase, it will perform any operations specific to that phase, then execute callbacks in that phase's queue until the queue is empty or the maximum callback limit is reached.

1. Timers Phase

This phase executes callbacks scheduled by setTimeout() and setInterval(). The event loop checks if any timers have expired, and if so, runs their callbacks. Note that the event loop cannot guarantee exact timing; it runs callbacks as close to the target delay as possible.

Advertisement

2. Pending Callbacks

This phase executes system-level callbacks that were deferred from the previous loop iteration. For example, if a TCP socket attempts to connect and receives an ECONNREFUSED error, some *nix systems will defer reporting this error, queueing it here.

3. Idle, Prepare

This phase is used only internally by libuv for housekeeping and preparing the next phase. You will never write code that executes during this phase.

4. Poll Phase

This is the most critical phase. The poll phase has two main responsibilities:

If the poll queue is not empty, the loop iterates through the queue of callbacks, executing them synchronously until the queue is empty. If the queue is empty, the loop checks if any setImmediate() callbacks are scheduled. If they are, it ends the poll phase and enters the Check phase.

5. Check Phase

This phase is dedicated to running callbacks scheduled with setImmediate(). Think of setImmediate() as a special timer that runs immediately after the poll phase finishes.

6. Close Callbacks Phase

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

Microtasks: process.nextTick() & Promises

Where do process.nextTick() and Promise callbacks (.then()) fit into this? They are not actually part of the libuv event loop! Instead, they are handled by Node.js/V8 directly as **microtasks**.

Microtask queues are checked and drained **immediately after the current operation finishes**, regardless of the current phase of the event loop. In other words, if you call process.nextTick(), its callback will execute before the event loop continues to the next phase.

โš ๏ธ Warning: Recursively calling process.nextTick() can starve the event loop! Because the event loop will wait for the microtask queue to drain, it will never transition to the next phase, causing your application to freeze (I/O starvation).

Practical Example

Let's look at a code snippet. Can you guess the output order?

const fs = require('fs');

console.log('1. Start');

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

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

fs.readFile(__filename, () => {
  setTimeout(() => {
    console.log('4. Timeout in I/O callback');
  }, 0);
  setImmediate(() => {
    console.log('5. Immediate in I/O callback');
  });
});

process.nextTick(() => {
  console.log('6. Next Tick');
});

console.log('7. End');

The Output Explained:

  1. 1. Start and 7. End are logged first because they are synchronous.
  2. 6. Next Tick runs next. The synchronous code finishes, and Node drains the microtask queue before starting the event loop.
  3. 2. Timeout 0ms or 3. Immediate will run next. In the main thread execution, the ordering depends on the performance of the system (under 1ms, timers can be unstable).
  4. Once the file is read, the event loop runs the file callback inside the **Poll** phase.
  5. Inside the Poll phase, the loop schedules a timer (4) and an immediate (5). Because the loop immediately transitions to the **Check** phase after Poll, 5. Immediate in I/O callback is guaranteed to execute before 4. Timeout in I/O callback.

Summary

Understanding the Event Loop helps you write non-blocking, high-performance Node.js code. By avoiding blocking operations on the main thread and properly structuring your callbacks, timers, and microtasks, you can build extremely scalable applications. Keep experimenting in the playground and write some asynchronous code to see these behaviors in action!