What is the 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. Since most modern kernels are multi-threaded, they can handle multiple operations executing in the background. When one of these operations completes, the kernel tells Node.js so that the appropriate callback may be added to the poll queue to eventually be executed.Event loop explained
When Node.js starts, it initializes the event loop, processes the provided input script (or drops into the REPL, which is not covered in this document) which may make async API calls, schedule timers, or callprocess.nextTick(), then begins processing the event loop.
The following diagram shows a simplified overview of the event loop’s order of operations:
Each box will be referred to as a “phase” of the event loop.
There is a slight discrepancy between the Windows and the Unix/Linux implementation, but that’s not important for this demonstration. The most important parts are here. There are actually seven or eight steps, but the ones we care about — ones that Node.js actually uses — are those above.
Phases overview
timers
Executes callbacks scheduled by
setTimeout() and setInterval().pending callbacks
Executes I/O callbacks deferred to the next loop iteration.
idle, prepare
Only used internally.
poll
Retrieve new I/O events; execute I/O related callbacks (almost all with the exception of close callbacks, the ones scheduled by timers, and
setImmediate()); node will block here when appropriate.check
setImmediate() callbacks are invoked here.close callbacks
Some close callbacks, e.g.
socket.on('close', ...).Starting with libuv 1.45.0 (Node.js 20), the event loop behavior changed to run timers only after the poll phase, instead of both before and after as in earlier versions. This change can affect the timing of
setImmediate() callbacks and how they interact with timers in certain scenarios.Phases in detail
timers
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. Timers callbacks will run as early as they can be scheduled after the specified amount of time has passed; however, Operating System scheduling or the running of other callbacks may delay them.Technically, the poll phase controls when timers are executed.
- ESM
- CJS
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 then wrap back to the timers phase to execute the timer’s callback. In this example, you will see that the total delay between the timer being scheduled and its callback being executed will be 105ms.
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.
pending callbacks
This phase executes callbacks for some system operations such as types of TCP errors. For example if a TCP socket receivesECONNREFUSED when attempting to connect, some *nix systems want to wait to report the error. This will be queued to execute in the pending callbacks phase.
poll
The poll phase has two main functions:- Calculating how long it should block and poll for I/O, then
- Processing events in the poll queue.
- If the poll queue is not empty, the event loop will iterate through its queue of callbacks executing them synchronously until either the queue has been exhausted, or the system-dependent hard limit is reached.
-
If the poll queue is empty, one of two more things will happen:
- If scripts have been scheduled by
setImmediate(), the event loop will end the poll phase and continue to the check phase to execute those scheduled scripts. - If scripts have not been scheduled by
setImmediate(), the event loop will wait for callbacks to be added to the queue, then execute them immediately.
- If scripts have been scheduled by
check
This phase allows the event loop to execute callbacks immediately after the poll phase has completed. If the poll phase becomes idle and scripts have been queued withsetImmediate(), the event loop may continue to the check phase rather than waiting.
setImmediate() is actually a special timer that runs in a separate phase of the event loop. It uses a libuv API that schedules callbacks to execute after the poll phase has completed.
Generally, as the code is executed, the event loop will eventually hit the poll phase where it will wait for an incoming connection, request, etc. However, if a callback has been scheduled with setImmediate() and the poll phase becomes idle, it will end and continue to the check phase rather than waiting for poll events.
close callbacks
If a socket or handle is closed abruptly (e.g.socket.destroy()), the 'close' event will be emitted in this phase. Otherwise it will be emitted via process.nextTick().
setImmediate() vs setTimeout()
setImmediate() and setTimeout() are similar, but behave in different ways depending on when they are called.
setImmediate()is designed to execute a script once the current poll phase completes.setTimeout()schedules a script to be run after a minimum threshold in ms has elapsed.
- ESM
- CJS
setImmediate() over setTimeout() is setImmediate() will always be executed before any timers if scheduled within an I/O cycle, independently of how many timers are present.
process.nextTick()
Understanding process.nextTick()
You may have noticed that process.nextTick() was not displayed in the diagram, even though it’s a part of the asynchronous API. This is because process.nextTick() is not technically part of the event loop. Instead, the nextTickQueue will be processed after the current operation is completed, regardless of the current phase of the event loop. Here, an operation is defined as a transition from the underlying C/C++ handler, and handling the JavaScript that needs to be executed.
Looking back at our diagram, any time you call process.nextTick() in a given phase, all callbacks passed to process.nextTick() will be resolved before the event loop continues.
Why would that be allowed?
Why would something like this be included in Node.js? Part of it is a design philosophy where an API should always be asynchronous even where it doesn’t have to be. Take this code snippet for example:process.nextTick() allowing it to take any arguments passed after the callback to be propagated as the arguments to the callback so you don’t have to nest functions.
What we’re doing is passing an error back to the user but only after we have allowed the rest of the user’s code to execute. By using process.nextTick() we guarantee that apiCall() always runs its callback after the rest of the user’s code and before the event loop is allowed to proceed. To achieve this, the JS call stack is allowed to unwind then immediately execute the provided callback which allows a person to make recursive calls to process.nextTick() without reaching a RangeError: Maximum call stack size exceeded from v8.
This philosophy can lead to some potentially problematic situations. Take this snippet for example:
someAsyncApiCall() to have an asynchronous signature, but it actually operates synchronously. When it is called, the callback provided to someAsyncApiCall() is called in the same phase of the event loop because someAsyncApiCall() doesn’t actually do anything asynchronously. As a result, the callback tries to reference bar even though it may not have that variable in scope yet, because the script has not been able to run to completion.
By placing the callback in a process.nextTick(), the script still has the ability to run to completion, allowing all the variables, functions, etc., to be initialized prior to the callback being called. It also has the advantage of not allowing the event loop to continue. It may be useful for the user to be alerted to an error before the event loop is allowed to continue. Here is the previous example using process.nextTick():
'listening' callback could be called immediately. The problem is that the .on('listening') callback will not have been set by that time.
To get around this, the 'listening' event is queued in a nextTick() to allow the script to run to completion. This allows the user to set any event handlers they want.
process.nextTick() vs setImmediate()
We have two calls that are similar as far as users are concerned, but their names are confusing.
process.nextTick()fires immediately on the same phasesetImmediate()fires on the following iteration or ‘tick’ of the event loop
process.nextTick() fires more immediately than setImmediate(), but this is an artifact of the past which is unlikely to change. Making this switch would break a large percentage of the packages on npm.
Why use process.nextTick()?
There are two main reasons:
- Allow users to handle errors, cleanup any then unneeded resources, or perhaps try the request again before the event loop continues.
- At times it’s necessary to allow a callback to run after the call stack has unwound but before the event loop continues.
listen() is run at the beginning of the event loop, but the listening callback is placed in a setImmediate(). Unless a hostname is passed, binding to the port will happen immediately. For the event loop to proceed, it must hit the poll phase, which means there is a non-zero chance that a connection could have been received allowing the connection event to be fired before the listening event.
Another example is extending an EventEmitter and emitting an event from within the constructor:
- ESM
- CJS
process.nextTick() to set a callback to emit the event after the constructor has finished, which provides the expected results:
- ESM
- CJS