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()orsetTimeout()) 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()orawait)
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:
- The synchronous lines (
StartandEnd) execute first. process.nextTick()executes immediately after the current phase.- The resolved
Promisecallback executes next from the microtask queue. - Then, the Event Loop continues to process timer callbacks (
setTimeout()). - 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.

