One of the most important aspects to understand about Node.js
Posted
Updated
Europeβs developer-focused job platform
Let companies apply to you
Developer-focused, salary and tech stack upfront.
Just one profile, no job applications!
This article is based on Node v16.14.0.
The event loop is a fundamental concept in Node.js and is what allows Node.js to perform non-blocking I/O operations. How is this possible, when JavaScript is single-threaded?
π° The Pragmatic Programmer: journey to mastery. π° One of the best books in software development, sold over 200,000 times.
The event loop is offloading operations to the system kernel whenever possible, and allows Node.js to perform non-blocking I/O operations. Most modern kernels are multi-threaded (they can handle multiple operations at the same time). When one of these kernel operations completes, Node.js gets an update (from the Kernel), so a callback gets added to the poll queue to be eventually executed.
When Node.js starts, it initializes the event loop, processes the provided code (index.js
or any other entry point for the application),
which may make asynchronous calls, schedule timers (setTimeout
, ...), calls proecss.nextTick()
, etc. and then starts with processing the event loop.
The simplified diagram from the Node.js docs shows the event loop's order. Every box in the diagram is a phase in the event loop.
βββββββββββββββββββββββββββββ
ββ>β timers β
β βββββββββββββββ¬ββββββββββββββ
β βββββββββββββββ΄ββββββββββββββ
β β pending callbacks β
β βββββββββββββββ¬ββββββββββββββ
β βββββββββββββββ΄ββββββββββββββ
β β idle, prepare β
β βββββββββββββββ¬ββββββββββββββ βββββββββββββββββ
β βββββββββββββββ΄ββββββββββββββ β incoming: β
β β poll β<ββββββ€ connections, β
β βββββββββββββββ¬ββββββββββββββ β data, etc. β
β βββββββββββββββ΄ββββββββββββββ βββββββββββββββββ
β β check β
β βββββββββββββββ¬ββββββββββββββ
β βββββββββββββββ΄ββββββββββββββ
ββββ€ close callbacks β
βββββββββββββββββββββββββββββ
Every phase has a FIFO (first-in first-out) queue of callbacks to execute. In general, when the event loop enters a phase it performs any operations specific to that phase, then execute callbacks in that queue (of the phase) until it's done (the queue has been exhausted, or the maximum numbers of callbacks has been executed). Then the event loop will move to the next phase and continues again with executing callbacks, and so forth.
The Event Loop is composed of several phases, which are repeated as long as the application has code that needs to be executed. In total there are seven or eight phases, depending on the OS, but only six phases are used by Node.js.
setTimeout()
and setInterval()
.socket.on('close', ...)
.Between each run of the event loop, Node.js checks if it is waiting for any asynchronous I/O or timers and shuts down cleanly if there are not any.
In Node.js timers are functions that execute callbacks after a set period.
The core timers module provides two global functions: setTimeout()
, and setInterval()
.
A timer specifies the threshold after which a provided callback may be executed rather than the exact time a person wants it to be executed. The callbacks of timers will run as early as they can be scheduled, after the set amount of time has passed. It could be that the OS scheduling, or the running of other callbacks will delay it.
Let's look at the example from the Node.js docs. We want to schedule a timeout with a 100 ms threshold, then a script starts asynchronously reading a file (which takes 95 ms):
const fs = require('fs');
function someAsyncOperation(callback) {
// Assume this takes 95ms to complete
fs.readFile('/path/to/file', callback);
}
const timeoutScheduled = Date.now();
setTimeout(() => {
const delay = Date.now() - timeoutScheduled;
console.log(`${delay}ms have passed since I was scheduled`);
}, 100);
// do someAsyncOperation which takes 95 ms to complete
someAsyncOperation(() => {
const startCallback = Date.now();
// do something that will take 10ms...
while (Date.now() - startCallback < 10) {
// do nothing
}
});
When the event loop enters the poll phase, it has an empty queue, because fs.readFile()
has not completed,
so it will wait for the number of ms remaining until the soonest timer's threshold is reached.
While it is waiting 95 ms pass, fs.readFile()
finishes reading the file and its callback which takes 10 ms to complete is added to the poll queue and executed.
When the callback finishes, there are no more callbacks in the queue, so the event loop will see that the threshold of the soonest timer has been reached and then gets back to the timers phase to execute the callback of the timer.
In this example the total delay between the timer being scheduled and its callback being executed will be 105ms.
Executing timer callbacks as part of the Event Loop explains the non-obvious behaviour that a timer's wait time is not exact, it is a minimum time which will pass before the callback is queued for execution.
When the application is waiting for a file to be read, it doesn't have to wait until the system gets back to it with the content of the file. It can do something else, since getting a coffee is not an option it will continue code execution and receive the file's content asynchronously when it is ready.
The asynchronous I/O request is recorded into the queue and then the main call stack can continue working as expected. In the second phase of the Event Loop the I/O callbacks of completed or errored out I/O operations are processed.
Let's look at an example:
fs.readFile('/file.md', (err, data) => {
if (err) throw err;
});
myAwesomeFunction();
The fs.readFile
operation is a I/O operation.
Node.js will pass the request to read a file to the filesystem of your OS.
Then the code execution will immediately continue past the fs.readFile()
code to myAwesomeFunction()
.
When the I/O operation is done(complete or error), a callback will be placed in the pending queue in the pending callbacks phase.
To prevent the poll phase from starving the event loop, libuv (the C library that implements the Node.js event loop and all of the asynchronous behaviors of the platform) also has a hard maximum (system dependent) before it stops polling for more events.
This phase is only used internally for housekeeping. The Event Loop performs internal operations of any callbacks. It is not possible to have direct influence on this phase, or its duration and code execution is not guaranteed during this phase.
In this phase all the JavaScript code is executed, starting at the top of the file, and working down. Depending on the code it may execute immediately, or it may add something to the queue to be executed during a future tick of the Event Loop.
The poll phase has two main functions:
When the event loop enters the poll phase and there are no timers scheduled:
setImmediate()
, the event loop will end the poll phase and continue to the check phase to execute those scheduled scripts.setImmediate()
, the event loop will wait for callbacks to be added to the queue, then execute them immediately.This phase may not happen on every tick of the event loop, depending on the application state.
Node.js has a special timer setImmediate()
and its callbacks are executed during this phase.
setImmediate()
allows executing callbacks immediately after the poll phase has completed.
It uses a libuv API
that schedules callbacks to execute after the poll phase has completed.
Hence, the check phase runs as soon as the poll phase becomes idle.
If the poll phase becomes idle and scripts have been queued with setImmediate()
,
the event loop may continue to the check phase rather than waiting.
Scrips set with setImmediate()
will always be executed before other timers regardless of how many timers are present.
This phase executes the callbacks of all close events.
For example, a close event of web socket callback, or when process.exit()
is called.
This is when the Event Loop is wrapping up one cycle and is ready to move to the next one.
It is primarily used to clean the state of the application.
Node.js sends time-consuming operations (I/O callbacks) to the C++ API and its threads. This allows simulation of multithreading within a single-threaded Node.js process, and the main runtime can continue to execute code without waiting.
With this architecture Node.js can benefit of asynchronous non-blocking I/O interface without being a memory hoarder.
The following keynote from Bert Belder at the Node.js Interactive Amsterdam Conference in 2016 explains the event loop very well.
The Event Loop is what keeps an application running. For example when running a server, the Event Loop is what notices new client requests and directs the creation of responses. Hence, if at any given time the Event Loop is blocked on the response for any client, current and upcoming clients will not get a response until it has completed processing the blocked request. I'd advise to ** not block the event loop with synchronous calls**.
When writing a Node.js application, it is critically important to ensure that all JavaScript callbacks that are executed in a timely manner and cannot freeze the application (blocked call stack).
Have a look at this great article from the Node.js docs Don't block the event loop.
Thanks for reading and if you have any questions, use the comment function or send me a message @mariokandut.
If you want to know more about Node, have a look at these Node Tutorials.
HeyNode, Node.js - Event Loop Guide, libuv, Node.js - Don't block the event loop
Never miss an article.