Node.js

Understanding How the Node.js Event Loop Works

Node.js is known for its ability to handle highly concurrent workloads using a single-threaded event-driven model. This design allows Node.js to manage thousands of simultaneous connections without spawning multiple threads or processes, a major advantage over traditional blocking I/O systems.

At the centre of this efficiency lies a concept called the Event Loop. The Event Loop allows Node.js to perform non-blocking I/O operations, even though JavaScript itself runs on a single main thread. It achieves this by offloading heavy or time-consuming tasks such as file system operations, network requests, or cryptographic computations to the background using libuv, and then returning to process results asynchronously.

This article takes a look at how the Event Loop works. It explores synchronous and asynchronous code, examines concurrency and parallelism, and discusses the architecture of the Event Loop.

1. Synchronous and Asynchronous Execution in JavaScript

JavaScript was originally designed for web browsers, where responsiveness is crucial.
If a piece of code takes too long to execute, it can freeze the entire user interface. That’s why JavaScript distinguishes between synchronous and asynchronous operations, a concept that remains essential in Node.js.

Synchronous Code: Step-by-Step and Blocking

In synchronous code, tasks are executed one after another in a strictly sequential manner. Each line waits for the previous one to complete before running. This is straightforward and predictable, but it becomes inefficient for I/O-heavy operations.

console.log('Start');
console.log('Fetching user data...');
console.log('End');

Output

Start
Fetching user data...
End

Here, every instruction runs in order. Suppose one operation takes time, such as fetching from a database. In that case, everything else is blocked until that task completes, making synchronous programming unsuitable for handling large-scale concurrent operations like HTTP requests or database queries.

Asynchronous Code: Non-Blocking and Event-Driven

Asynchronous code, on the other hand, allows Node.js to initiate a task and move on immediately, without waiting for it to finish. When the operation eventually completes, a callback function, promise, or async/await handler takes care of the result.

console.log('Start');

setTimeout(() => {
    console.log('Async operation finished');
}, 2000);

console.log('End');

Output

Start
End
Async operation finished

Even though the delay is two seconds, Node.js doesn’t halt execution. It registers the asynchronous task in the background and continues. When the timer expires, the callback is queued for execution in a later phase of the Event Loop. This behaviour enables Node.js to serve multiple clients simultaneously, as one task doesn’t block another.

2. Distinguishing Concurrency and Parallelism

The terms “concurrency” and “parallelism” are often used interchangeably, but they refer to distinct concepts.

  • Concurrency means dealing with many tasks at once. In Node.js, concurrency is achieved through event scheduling, where multiple I/O operations are started, and their results are processed as they complete.
  • Parallelism, on the other hand, means performing multiple tasks literally at the same time, often on different CPU cores.

JavaScript in Node.js runs on a single thread, so it isn’t parallel in the traditional sense. However, Node.js can perform I/O operations in parallel via libuv’s thread pool, and we can explicitly achieve parallelism using worker threads.

This makes Node.js efficient for I/O bound workloads (such as APIs, web servers, and file streaming) but less optimal for CPU-intensive computations (like encryption or image processing), unless worker threads are used.

3. Understanding the Node.js Event Loop

The Event Loop is the central mechanism that manages the execution of asynchronous operations in Node.js, ensuring that the single-threaded nature of JavaScript remains efficient and does not become a performance bottleneck.

Every Node.js process runs inside a single-threaded event loop, but it’s supported by background threads for offloading I/O operations. This allows Node.js to stay responsive, even when handling thousands of requests. Here’s a conceptual summary of how it works:

  • The main thread executes JavaScript code, running functions and maintaining a call stack.
  • When an asynchronous operation (like fs.readFile() or setTimeout()) is encountered, Node.js delegates it to the libuv thread pool.
  • Once the background operation completes, its callback is queued for execution.
  • The Event Loop continuously checks for these queued callbacks and executes them in various phases.

This constant cycle of delegating, polling, and processing is what keeps Node.js applications alive and responsive.

4. The Internal Phases of the Event Loop

The Event Loop is divided into multiple phases, each responsible for handling a specific type of operation. Every cycle of the Event Loop, known as a tick, goes through these phases in a clearly defined order. The following sections explain each phase in detail.

Timers Phase: Executing Scheduled Timers

The timers phase handles the execution of callbacks scheduled by setTimeout() and setInterval() once their specified delay or interval has elapsed. These timers do not guarantee exact timing but ensure that the callbacks are executed as soon as possible after the delay, depending on the Event Loop’s workload.

Example using setTimeout()

setTimeout(() => {
    console.log('setTimeout callback executed after 1000ms');
}, 1000);

console.log('Timer set using setTimeout');

Output

Timer set using setTimeout
setTimeout callback executed after 1000ms

Here, the setTimeout() callback is scheduled to run after 1000 milliseconds (1 second). However, the exact timing depends on the Event Loop’s current phase and pending tasks.

Example using setInterval()

let counter = 0;

const intervalId = setInterval(() => {
    counter++;
    console.log(`setInterval callback executed ${counter} time(s)`);

    if (counter === 3) {
        clearInterval(intervalId);
        console.log('Interval cleared after 3 executions');
    }
}, 2000);

console.log('Timer set using setInterval');

Output

Timer set using setInterval
setInterval callback executed 1 time(s)
setInterval callback executed 2 time(s)
setInterval callback executed 3 time(s)
Interval cleared after 3 executions

In this example, the setInterval() function executes its callback repeatedly every 2 seconds until it is manually cleared using clearInterval(). This demonstrates how the timers phase continuously manages scheduled tasks without blocking the Event Loop.

Pending Callbacks Phase: System-Level Callbacks

This phase executes I/O callbacks that were deferred from the previous loop iteration, often internal system operations. For example, when certain TCP errors or DNS lookups finish, their callbacks are queued here. This process is mostly handled internally by Node.js, and we rarely need to interact with it directly.

Idle, Prepare Phase: Internal Housekeeping

In this phase, Node.js and libuv perform internal operations to prepare for the next iteration of the loop.
You won’t write code that explicitly runs in this phase, but it’s crucial for maintaining the system’s stability.

Poll Phase: Retrieving and Executing I/O Events

The poll phase is the core of the Event Loop, where Node.js waits for new I/O events such as reading files, receiving HTTP requests, or completing network operations. During this phase, Node.js retrieves and processes callbacks from completed I/O operations. If there are callbacks waiting in the queue, they are executed immediately. If not, the Event Loop may pause briefly while waiting for incoming events, unless there are timers or setImmediate() callbacks scheduled, in which case it proceeds to the next phase.

Example: Handling Network I/O (HTTP Requests)

const http = require('http');

const server = http.createServer((req, res) => {
    console.log('Request received:', req.url);
    res.writeHead(200, {'Content-Type': 'text/plain'});
    res.end('Hello from the poll phase!\n');
});

server.listen(3000, () => {
    console.log('Server running on http://localhost:3000');
});

Output (after making a few requests)

Server running on http://localhost:3000
Request received: /
Request received: /about
Request received: /favicon.ico

In this example, the HTTP server listens for incoming network requests. Each time a client sends a request, the event is captured during the poll phase, and the callback associated with the request is executed. The server remains nonblocking, meaning multiple requests can be processed efficiently even if one of them takes longer to complete.

Check Phase: Running setImmediate Callbacks

Callbacks registered with setImmediate() are executed in this phase. Unlike setTimeout(), which schedules execution after a specified delay, setImmediate() runs right after the poll phase completes.

const fs = require('fs');

fs.readFile(__filename, () => {
    console.log('File read complete (I/O callback)');

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

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

console.log('Synchronous log - start of program');

Output

Synchronous log - start of program
File read complete (I/O callback)
setImmediate callback executed
setTimeout callback executed

In this example, the file is read asynchronously using fs.readFile(), which queues its callback in the poll phase. Once the poll phase finishes processing the I/O event, the check phase runs next, executing the setImmediate() callback, followed by the setTimeout() callback in the timers phase of the next event loop iteration.

This demonstrates that setImmediate() is ideal for running code right after I/O operations complete, ensuring it executes before other scheduled timers and without waiting for another full event loop cycle.

Close Callbacks Phase: Cleanup Operations

This final phase handles cleanup activities. When sockets or handles close unexpectedly (for example, a client disconnecting), their corresponding “close” events are processed here.

const net = require('net');
const server = net.createServer((socket) => {
    socket.on('close', () => {
        console.log('Socket connection closed');
    });
});
server.listen(3000);

When a connection ends, the cleanup callback executes during this phase.

Microtasks: process.nextTick() and Promises

Apart from the major Event Loop phases, Node.js also manages microtasks, which include:

  • process.nextTick() callbacks
  • Promise resolution callbacks (e.g., .then() or await)

Microtasks are executed between phases, specifically right after the current operation completes and before the Event Loop proceeds to the next phase. Example:

console.log('Start');

// process.nextTick() queues a microtask with the highest priority
process.nextTick(() => {
    console.log('Microtask 1 (process.nextTick) executed');
});

// Promise callbacks are also queued as microtasks, but after nextTick
Promise.resolve().then(() => {
    console.log('Microtask 2 (Promise.then) executed');
});

// queueMicrotask() also queues a microtask at the same priority level as Promise callbacks
queueMicrotask(() => {
    console.log('Microtask 3 (queueMicrotask) executed');
});

// Regular timer callback (executed in the timers phase)
setTimeout(() => {
    console.log('Timeout callback executed');
}, 0);

console.log('End');

Expected Output

Start
End
Microtask 1 (process.nextTick) executed
Microtask 2 (Promise.then) executed
Microtask 3 (queueMicrotask) executed
Timeout callback executed

In Node.js, process.nextTick() has the highest priority among microtasks and always runs before Promise or queueMicrotask() callbacks. All microtasks execute immediately after the current synchronous operation completes but before the Event Loop advances to the next phase. The setTimeout() callback, in contrast, runs later during the timers phase of the next event loop iteration.

5. Bringing It All Together

Let’s analyse a practical example that combines multiple async operations.

console.log('Start');

setTimeout(() => console.log('Timeout'), 0);
setImmediate(() => console.log('Immediate'));
Promise.resolve().then(() => console.log('Promise'));
process.nextTick(() => console.log('Next Tick'));

console.log('End');

Output

Start
End
Next Tick
Promise
Timeout
Immediate

Here’s what happens:

  1. The synchronous lines (Start and End) execute first.
  2. process.nextTick() executes immediately after the current phase.
  3. The resolved Promise callback executes next from the microtask queue.
  4. Then, the Event Loop continues to process timer callbacks (setTimeout()).
  5. Finally, setImmediate() runs during the check phase.

6. Conclusion

The Node.js Event Loop is one of the most elegant aspects of its architecture, enabling JavaScript, a single-threaded language, to handle multiple operations concurrently. By understanding how the Event Loop, microtasks, and background threads work together, we can avoid blocking the main thread, optimise performance for I/O intensive applications, and write cleaner, more efficient asynchronous code.

This article explored how the Nodejs Event Loop works in managing asynchronous operations.

Omozegie Aziegbe

Omos Aziegbe is a technical writer and web/application developer with a BSc in Computer Science and Software Engineering from the University of Bedfordshire. Specializing in Java enterprise applications with the Jakarta EE framework, Omos also works with HTML5, CSS, and JavaScript for web development. As a freelance web developer, Omos combines technical expertise with research and writing on topics such as software engineering, programming, web application development, computer science, and technology.
Subscribe
Notify of
guest

This site uses Akismet to reduce spam. Learn how your comment data is processed.

0 Comments
Oldest
Newest Most Voted
Inline Feedbacks
View all comments
Back to top button