Node.js Event Loop
The event loop is what allows Node.js to perform non-blocking I/O operations — despite the fact that a single JavaScript thread is used by default — by offloading operations to the system kernel whenever possible.
The secret is asynchronous, non-blocking I/O. When you do something like fs.readFile(), Node doesn't wait; it offloads the work to the OS kernel (via libuv, Node's C++ underbelly for cross-platform async). The kernel uses its own threads or mechanisms to handle the task in the background. When done, it notifies Node, and the callback gets queued up.
JavaScript is blocking
JavaScript is blocking because of its synchronous nature. No matter how long a previous process takes, the subsequent processes won't kick off until the former is completed. In the code snippet, if function A has to execute an intensive chunk of code, JavaScript has to finish that without moving on to function B. Even if that code takes 10 seconds or 1 minute.
You might have experienced this in the browser. When a web app runs in a browser and it executes an intensive chunk of code without returning control to the browser, the browser can appear to be frozen. This is called blocking. The browser is blocked from continuing to handle user input and perform other tasks until the web app returns control of the processor.
The event loop doesn’t constantly keep spinning. If there are no active I/O handlers and the event loop doesn’t need to process any callbacks, the Node.js program automatically exits. On the other hand, the event loop stops on the poll phase to capture incoming requests whenever you create a web server and the phases are empty.
Once Node.js starts the program, the event loop processes all the queues and blocks on the poll phase waiting for incoming requests. When someone makes a GET HTTP request to the server, the event loop goes through these steps:
The event loop pushes the request handler on the call stack.
A
setImmediatecallback is added in the check phase.A
setTimeoutcallback is scheduled in the timers phase.The event loop moves to the check phase. The
setImmediatecallback is popped off from the queue and pushed on the call stack for execution.The event loop does not move any callbacks from empty queues.
The
setTimeoutcallback is popped off from the timer queue and pushed on the call stack for processing.
In addition, you know that event loop blocks on the poll phase waiting for new I/O requests on a Node.js web server.
The code is available at:
https://github.com/libuv/libuv/blob/v1.x/src/unix/core.c#L384
Although Node.js is single-threaded, it can offload CPU-intensive tasks to a worker thread pool via libuv.
const { Worker } = require('worker_threads');
const worker = new Worker(`
const { parentPort } = require('worker_threads');
let sum = 0;
for (let i = 0; i < 1e9; i++) sum += i;
parentPort.postMessage(sum);
`, { eval: true });
worker.on('message', result => console.log("Worker Result:", result));Worker Threads execute tasks in parallel without blocking the event loop.
Note: There are other ways to solve this problem as well like running the loops in an async manner by slicing the loop in chunks and using promises to resolve it, the example is showing how it can be handled via Worker threads.
Step 1: JavaScript Execution Begins (Main Thread)
Synchronous code runs first.
When an asynchronous function is encountered:
1. The operation is offloaded (to a background thread in libuv for I/O, Timers, etc.).
2. The callback is registered to run later.JavaScript continues executing without waiting.
Step 2: The Event Loop Detects Completed Async Tasks
Once an async operation finishes (e.g., a file is read or a timer expires), its callback moves to the appropriate queue.
The event loop continuously checks if any queue contains tasks ready for execution.
I/O-bound tasks (file system, database) don’t block the event loop.????
CPU-bound tasks (large calculations) block the event loop.
Step 3: Processing Tasks in Event Loop Phases
The event loop follows six phases in a continuous cycle.
Timers Phase ⏳
I/O Callbacks Phase 📡
Idle, Prepare (Internal Use)
Poll Phase 🔄 (fs, http, crypto thread pool, network, etc.)
Check Phase ✅
Close Callbacks Phase 🚪
Step 4: Microtasks (High Priority Tasks)
After each phase, before moving to the next one, Node.js checks the Microtask Queue.
process.nextTick() and Promise callbacks (.then()) are executed before returning to the event loop.
Node.js uses a single thread : JavaScript runs on a single thread, but asynchronous operations use libuv and worker threads.
First, there is the timer queue (technically a min-heap), which holds callbacks associated with setTimeout and setInterval.
Second, there is the I/O queue which contains callbacks associated with all the async methods such as methods associated with the
fsandhttpmodules.Third, there is the check queue which holds callbacks associated with the setImmediate function, which is specific to Node.
Fourth, there is the close queue which holds callbacks associated with the close event of an async task.
It is important to note that the timer, I/O, check, and close queues are all part of libuv. The two microtask queues, however, are not part of libuv. Nevertheless, they are still part of the Node runtime and play an important role in the order of execution of callbacks. Speaking of which, let's understand that next.
When an async task completes in libuv, at what point does Node decide to run the associated callback function on the call stack?
Answer:
Callback functions are executed only when the call stack is empty.