Node.js’s event loop is not a single thread that handles everything; it’s a sophisticated orchestration of multiple threads and specialized APIs that allows JavaScript to perform I/O operations without blocking.

Let’s see the event loop in action with a common asynchronous operation: reading a file.

const fs = require('fs');

console.log('Start reading file...');

fs.readFile('example.txt', 'utf8', (err, data) => {
  if (err) {
    console.error('Error reading file:', err);
    return;
  }
  console.log('File content:', data);
});

console.log('Finished reading file setup.');

If example.txt contains "Hello, Node.js!", the output will be:

Start reading file...
Finished reading file setup.
File content: Hello, Node.js!

Notice how "Finished reading file setup." appears before the file content. This is the hallmark of asynchronous non-blocking I/O, managed by the event loop.

The core problem Node.js solves is enabling highly concurrent, I/O-bound applications (like web servers) to handle many connections simultaneously without the overhead of traditional thread-per-request models. Instead of threads waiting idly for I/O, Node.js uses an event-driven, non-blocking architecture. When an I/O operation is initiated (like fs.readFile), Node.js offloads the work to the operating system or a thread pool (for certain operations). It then registers a callback function to be executed when the operation completes. While the I/O is happening in the background, the main JavaScript thread is free to process other tasks, such as handling new incoming requests or executing other JavaScript code.

The event loop itself is a continuous process that checks for pending callbacks. It has several phases:

  1. Timers: Executes callbacks scheduled by setTimeout() and setInterval().
  2. Pending Callbacks: Executes I/O callbacks that were deferred to the next loop iteration.
  3. Idle, Prepare: Used internally by Node.js.
  4. Poll: Retrieves new I/O events; executes I/O-related callbacks (e.g., network, file system). This is where the loop waits for I/O.
  5. Check: Executes callbacks scheduled by setImmediate().
  6. Close Callbacks: Executes close event callbacks, e.g., socket.on('close', ...).

When an asynchronous operation like fs.readFile completes, its callback is placed in a queue. The event loop, during its "Poll" phase, will pick up these completed operations and execute their associated callbacks. process.nextTick() callbacks are executed immediately after the current operation completes, before the event loop moves to the next phase. setImmediate() callbacks are executed in the "Check" phase, after the "Poll" phase completes.

The V8 JavaScript engine executes your JavaScript code, but Node.js adds a layer of C++ APIs that interact with the operating system and manage asynchronous operations. Libraries like libuv are fundamental here, providing the cross-platform asynchronous I/O capabilities that Node.js relies on. When you call fs.readFile, Node.js (via libuv) initiates the file read operation. If the operation can be completed immediately (e.g., file is small and already in OS cache), the callback might run in the same tick. Otherwise, it’s handed off to the OS or a thread pool, and the callback is queued for later execution by the event loop.

Understanding the difference between process.nextTick() and setImmediate() is crucial for fine-grained control over callback execution order, especially in complex scenarios. process.nextTick() is not technically part of the event loop phases; it has higher priority and its callbacks are executed before the event loop continues to the next phase or even before I/O callbacks in the poll phase are processed. setImmediate(), on the other hand, runs in its own phase after the poll phase, making it suitable for breaking up long-running operations without blocking I/O.

The concept of "microtasks" and "macrotasks" is also essential for a complete picture. Promises and async/await introduce microtask queues, which have higher priority than macrotasks (like event loop phases and their callbacks). Microtasks are executed after the current operation completes and before the event loop moves to the next macrotask. This explains why promise resolutions often appear to happen very quickly, even between seemingly synchronous-looking code blocks.

The interaction between process.nextTick(), setImmediate(), and promise microtasks reveals a nuanced execution order that can surprise developers accustomed to simpler execution models.

Want structured learning?

Take the full Nodejs course →