The event loop can be called the heart of Node.js. It is used to handle asynchronous I/O tasks in Node.js. So how is it structured and how does it work? Let's find out in the following article.
What is the Event Loop?
The event loop allows Node.js to perform asynchronous I/O tasks, even though JavaScript is single-threaded by actually offloading operations to the operating system whenever possible.
Since most modern kernels are multi-threaded, they can handle multiple operations executing in the background. When one of these operations completes, the kernel notifies Node.js so that the attached callback function can be added to the poll queue and eventually executed.
How does the Event Loop work?
When Node.js starts, it initializes the Event Loop, processes any input script that it has been provided (or an REPL), which may include performing some asynchronous functions, schedulling timers, or process.nextTick(), and after that starts processing the Event Loop.
The following diagram provides a high-level overview of the sequence of events in the Event Loop.
Note: each block is considered as one "phase" of the event loop.
Each phase has a FIFO queue of callbacks. Each phase has a specific task, but generally, when the Event Loop steps into a specific phase, it will process any data for that phase and then execute the callbacks in the queue for that phase until it is empty or reaches the execution limit. Then the Event Loop moves on to the next phase.
Since each phase can have a large number of callbacks waiting to be processed, some of the callbacks for timers may have a longer wait time than the initial threshold set, as the initial time threshold only guarantees the shortest wait time, not the exact waiting time.
For example,
setTimeout(() => console.log('hello world'), 1000);
The 1000ms is the shortest waiting time, but it doesn't mean that the console.log
statement will be executed exactly after 1000ms.
Overview of Event Loop phases
- Timers: Executes callbacks scheduled by
setTimeout()
andsetInterval()
. - Pending callbacks: Executes I/O callbacks deferred to the next loop iteration.
- Idle, prepare: Used for internal purposes by Node.js.
- Poll: Retrieves new I/O events, executes related callbacks (almost all with the exception of close callbacks, timer callbacks, and
setImmediate()
). - Check: Executes
setImmediate()
callbacks. - Close callbacks: Executes close callbacks, e.g.
socket.on("close")
.
Between each iteration of the Event Loop, Node.js checks if it is waiting for any asynchronous I/O or timers and exit if there is none.
Detailed Event Loop phases
Timers
A timer specifies a threshold after which a callback can be executed. The timer callbacks will run as soon as possible after the specified amount of time has passed. However, they can also be delayed for some time.
Note: Technically, poll
controls when the timers are executed.
For example, let's say we set up a setTimeout()
that executes after 100ms and then run an someAsyncOperation
function that asynchronously reads a file and takes 95ms to complete:
const fs = require('fs');
function someAsyncOperation(callback) {
// assume reading a file takes 95ms
fs.readFile('/path/to/file', callback);
}
const timeoutScheduled = Date.now();
setTimeout(() => {
const delay = Date.now() - timeoutScheduled;
console.log(`${delay}ms`);
}, 100);
// someAsyncOperation takes 95ms to complete
someAsyncOperation(() => {
const startCallback = Date.now();
// event loop will be delayed by 10ms...
while (Date.now() - startCallback < 10) {
// do nothing
}
});
When the Event Loop steps into the poll phase and there are no callbacks for timers, one of the two things happens:
- If the poll queue is not empty, the Event Loop will iterate through its callbacks and execute them one by one until the queue is empty or it reaches the system-specified limit.
- If the poll queue is empty, one of the two things happens:
- If there are
setImmediate()
callbacks scheduled, the Event Loop will exit the poll phase and proceed to the check phase to execute those scheduled callbacks. - If there are no
setImmediate()
callbacks scheduled, the Event Loop will wait for callbacks to be added to the queue and then execute them immediately.
When the poll queue is empty, the Event Loop checks if any timers have reached their threshold for execution. If one or more timers are ready, the Event Loop will go back to the timers phase to execute those callbacks.
Pending callbacks
This phase executes callback functions for some system operations, such as certain types of TCP errors. For example, if a TCP socket receives ECONNREFUSED when trying to connect, some *nix systems want to wait to report the error. It will be queued here to await its turn.
Poll
The poll phase has two main functions:
- Calculate how long it should block and poll for I/O events, then:
- Handle events from the poll queue
When the Event Loop steps into the poll phase and there are no timer callbacks, one of two things will happen:
- If the poll queue is not empty, the Event Loop will iterate through its callbacks and execute them one by one until the queue is empty or it hits the limit of the system.
- If the poll queue is empty, one of two things will happen:
- If there are scheduled commands by
setImmediate()
, the Event Loop will exit the poll phase and proceed to the check phase to execute those scheduled commands. - If there are no commands scheduled by
setImmediate()
, the Event Loop waits for callbacks to be added to the queue and then executes them immediately.
When the poll queue is empty and there are no setImmediate()
callbacks scheduled, the Event Loop checks if any timers have reached their execution threshold. If one or more have, the Event Loop will go back to the timers phase to execute those callbacks.
Check
This phase allows us to execute callbacks immediately after the poll phase completes. If the poll phase is idle and there are setImmediate()
callbacks scheduled, the Event Loop can proceed to this phase instead of waiting for poll events.
setImmediate()
is a special timer that runs in a separate phase of the Event Loop. It uses libuv API to schedule the execution of callbacks after the poll phase completes.
In general, once the code is executed, the Event Loop eventually reaches the poll phase - where it will wait for incoming connections, requests, etc… However, if a callback is scheduled by setImmediate()
and the poll phase is in an idle state, it will end and continue to the check phase instead of waiting for the poll events.
Close callback
If a socket or handle is closed unexpectedly (e.g., socket.destroy()
), then the 'close' event will be emitted in this phase. Otherwise, it will be emitted via process.nextTick()
.
Summary
The Event Loop in Node.js is implemented using libuv and consists of 6 phases, each handling a separate part of the work. Knowing this, we can explain the priority order of executing callbacks for functions like setTimeout, setImmediate, or process.nextTick. Regarding the priority order and the benefits of each, I will address this in another article. See you later!