In this part, we will go through some Node.js code snippets to see how the event loop behaves and how it affects code execution. But first, let’s recap how the event loop actually works by using a diagram from this talk by Bert Belder.
Let’s start with a simple example: calling setTimeout.
This code will print “Start” on the console. Then it will call the setTimeout function and the timer will be added to the timers’ heap, managed by Libuv. It will then print “End”. In the background, Libuv is handling the timeout. After two seconds the timer expires and the callback is added to the timers’ queue. The event loop will check the timers’ queue and will execute the callback, which will print “Timeout callback executed” on the console. On the next iteration, the event loop will check if it still has work to do (which will result in false) and the process will exit.
When working with setTimeout, we have to keep in mind that the timeout value that we put as an argument represents the least amount of time we will wait until the execution of the callback. In our case, the timer will expire after two seconds, but the actual execution of the callback might happen a little later, depending on the load of the operating system and the number of callbacks in the queue. Let’s modify the code slightly and see what happens.
If we execute the code several times, we will get different results for the time required to execute the callback. The change will be even more significant if there are several timer callbacks to execute.
Judging by the event loop diagram, the immediates phase is located right behind the I/O phase, so everything we put in a setImmediate callback will be processed after executing I/O callbacks and polling. Let’s add a setImmediate function call in our code. But first, we will slightly modify the setTimeout function and put a timeout of 0 milliseconds.
We are certain that “Start” and “End” will be printed first. About the next piece of code, one might judge that the timeout will expire (since it is 0 milliseconds), the timeout callback will be executed first, then the immediate callback will be executed. Actually, this part of the output is uncertain. If we execute it multiple times, we might see that the timeout callback and the immediate callback switch places.
Based on Node.js docs, if both setTimeout and setImmediate are called from within the main module, the output is non-deterministic because it depends on the system performance (how many processes are running on the machine). Also, instant timeouts do not exist in Node. If we look at this piece of code which represents a Timer object, we see that if the timeout value is 0 or bigger than TIMEOUT_MAX (i.e. max integer value), the timeout becomes 1 millisecond (i.e. minimum delay for a timeout in Node).
If we want to make the above output deterministic, we can put the setImmediate and setTimeout inside an I/O callback.
Let’s see what happens step by step:
- The main module execution starts. The first statement prints “Start” on the console.
- The readFile method of the fs module is invoked. This is an asynchronous call, so it won’t block the execution of the statements below it.
- “End” is printed on the console.
- The file is read on the background (managed by Libuv). Since there is nothing else to be done, the event loop will block and poll for I/O. When the file is read, the callback will be added to the I/O queue and the event loop will immediately schedule it for execution. Inside the callback, we call setTimeout first, then we call setImmediate and then we invoke console.log. A timer will be added to the timers’ heap and a callback will be added to the immediates’ queue.
- We move out of the I/O phase of the event loop and the “File read” statement will be printed on the console.
- The next phase is the immediates’ phase. The queue is checked and since we have a callback ready, it is executed, and “Immediate callback executed” is printed on the console.
- On the next iteration of the event loop, the timers queue is checked. There is already a callback, which is executed, and “Timeout callback executed” will be printed on the console.
- On the final iteration, there is nothing else to do so the process will exit.
This way, we make the output deterministic. We know for sure that the immediates callback will be executed before the timeout callback since it is executed right after the I/O phase, whereas the timers phase is at the beginning of the event loop and the callback will always be executed on the next iteration of the loop.
We already know that next-tick and resolved promises are not shown on the event loop diagram because they are not part of Libuv. Instead, they belong to Node (yellow JS blocks on the first event loop diagram) and are represented as an event loop on their own (second diagram). This means that whenever we call process.nextTick or Promise.resolve, the callbacks will be executed immediately after the current phase of the event loop. The event loop will block until the microtasks’ queue is empty. Let’s modify the code and add some microtasks.
The execution goes on like this:
- Firstly, all synchronous code will execute, which means that “Start” and “End” will be printed on the console. We also schedule the following code for execution: First next-tick, first promise resolve, a file read, third promise resolve, and third next-tick. All the corresponding callbacks are added to their own queues. So, we have two callbacks on the next-tick queue, two resolved promises on the promises’ queue and we have initiated a file read.
- After the event loop starts, it sees no callbacks on the timers’ queue. It moves on to the JS block (next-ticks and promises queue). It has two callbacks on each of them. Next ticks have a higher priority than resolved promises’ queue, which means that both next-tick callbacks will be executed first and the two resolved promises’ callbacks will be executed next.
- The event loop moves to the I/O phase. Here it calculates if it will block and poll for I/O and how much time it will spend blocking. As a reminder, the event loop will block only if there is nothing else to do next (no expired timers, immediates, close handlers, etc…). If we had an immediate callback waiting to be executed (called from the main module), it would most probably execute that and on the next iteration of the event loop, it would go on and block for I/O. However, this is not the case here. In our example, the event loop will block for I/O immediately after promises’ callbacks are executed.
- The file is read and an event is emitted, which adds the I/O callback on the I/O queue. Event loop will immediately schedule this callback for execution. Inside the callback we call setTimeout, setImmediate, log the result of the file in the console, call process.next tick and finally a Promise.resolve. Let’s see how this block is executed.
- It will start by executing the console.log statement, which is a synchronous operation and belongs to the JS blocks. In the same block, we have two callbacks scheduled for execution, the second next-tick callback, and the second promise-resolve callback. Although promise-resolve is called first, it’s callback will be executed second (because next-tick has higher priority like it was mentioned above). So, the final output for this step would be “File read”, “Second next-tick callback executed”, “Second promise was resolved”, in that order.
- The event loop moves to the next phase, the check phase where all immediate callbacks are executed. “Immediate callback executed” will be printed on the console.
- The event loop will start the next iteration and in the first phase, it will execute the expired timer’s callback. So, “Timeout callback executed” will be logged.
- Since there is no more work, this is the last iteration of the event loop. The process will exit.
The complete output:
As you might have noticed, the way microtasks are designed makes it possible for the programmer to block the execution of the event loop. For example, if we recursively call process.nextTick the next-tick queue will be filled infinitely and all these callbacks will be scheduled for execution before the next event loop phase. The next phase will never be reached.
The final phase of the event loop executes close handlers’ callbacks. Let’s add one small piece of code on our example, to track when the process exits.
The output will be the same as in the previous example, except for the close callback that will be executed on the final iteration of the event loop, just before the process exits.
As it is shown in the examples above, code in Node.js can be executed differently from the way it is written (i.e. statements are not executed in the order they appear in code). Understanding the event loop and the details behind it can help us avoid unexpected behaviors and write correct and more performant code.
Full source code and other examples related to the event loop: https://github.com/zeroabsolute/Node-Internals