Event Loop and Non-Blocking I/O


  One of the fundamental concepts for understanding why Node.js is so efficient and scalable is the Event Loop and its focus on Non-blocking I/O.


  Unlike other languages or environments that use a thread-per-request model, Node.js operates with a single main thread and an asynchronous, non-blocking I/O model.


What is Non-Blocking I/O?

  I/O (Input/Output) operations refer to any interaction with external resources of the program, such as reading from a file, writing to a database, or making a network request.

  • Blocking: When an I/O operation is blocking, the program (or the execution thread) must wait for the operation to complete before it can proceed with the next task. This can be inefficient, especially if the I/O operation takes a long time, as the program remains "waiting" and cannot do anything else.
  • Non-Blocking: In contrast, non-blocking I/O operations allow the program to initiate an I/O operation and then immediately continue executing other tasks, without waiting for the I/O operation to finish. When the I/O operation completes, the program is notified (via a callback or a promise), and the result is then processed.

  Node.js uses this latter approach. When you make a database call or read a file, Node.js does not stop to wait. Instead, it sends the task to the operating system, registers a callback, and continues executing the rest of your code. Once the I/O operation completes, the operating system notifies Node.js, and the associated callback is placed in the event queue to be processed by the Event Loop.

The Event Loop

  The Event Loop is the heart of Node.js's concurrency model. It is a continuous process that monitors the event queue and executes callbacks of asynchronous operations once they have completed. Although Node.js is single-threaded for the execution of your JavaScript code, the underlying I/O operations are handled by the operating system or by an internal thread pool (known as libuv, which Node.js uses).

The Event Loop consists of several phases:

  • Timers: Executes callbacks scheduled by setTimeout() and setInterval().
  • Pending Callbacks: Executes callbacks of deferred I/O operations.
  • Idle, Prepare: Internal to Node.js.
  • Poll: This is the most important phase. Node.jslooks for new completed I/O operations and executes their callbacks. If there are no pending callbacks, the Event Loop can pause here waiting for new I/O tasks or move to the `check` phase.
  • Check: Executes callbacks of setImmediate().
  • Close Callbacks: Executes callbacks for close events, such as socket.on('close', ...).

  When an asynchronous operation (like a file read or a network request) completes, its callback is placed in the corresponding event queue. The Event Loop constantly checks these queues and, when the main call stack is empty, pulls a callback from the queue and executes it. This cycle repeats indefinitely as long as there are pending tasks.

Simple Example

Consider the following code:

console.log("Script start");

setTimeout(() => {
  console.log("This runs after the timeout (timers phase)");
}, 0);

const fs = require('fs');
fs.readFile('./myFile.txt', 'utf8', (err, data) => {
  if (err) throw err;
  console.log("File content (poll phase)");
});

Promise.resolve().then(() => {
  console.log("This is a microtask (runs before the next phase)");
});

console.log("Script end");

The output order is not necessarily the order of appearance in the code. The Event Loop ensures that non-blocking operations do not halt the execution of the main code, and that callbacks are executed once the underlying asynchronous operations have completed. Promises and process.nextTick are handled as "microtasks," which have priority over "macrotasks" (like setTimeout or I/O) and are executed before the Event Loop moves to the next phase.

JavaScript Concepts and Reference