In my last post(Deep dive into Node.js Architecture), we learnt about the internals of Node.js and how multiple asynchronous operations work efficiently in a single-threaded environment. We also talked about how event loop works and facilitates the event-driven architecture of Node.js. I would recommend going through my previous post before reading this one.
In this article, we are going to learn more about event loop and it's different phases along with a code example.
Before we start, you might ask why does a Node.js developer need to know about Event loop. The answer to this is:
event loop is the one that handles all the scheduling of your application code and any misconceptions regarding this can lead to poor performance and buggy code,
and it is a very important interview question if you are applying for Nodejs backend role.
so, Let's start :)
As we discussed earlier, Event loop is nothing but a loop. It just loops over the set of events sent by Synchronous Event Demultiplexer, trigger callbacks and move the application along.
The event loop has several different phases to it and each one of these phases maintains a queue of callbacks that are to be executed. Callbacks are destined for different phases based on how they are used by the application.
- The poll phase executes I/O-related callbacks.
- Most of the application code executes in this phase.
- Starting point of Node.js application
- In this phase, callbacks that are triggered via
- This phase executes callbacks triggered via
EventEmitter close events.
- For example, when a net.Server TCP server closes, it emits a close events that runs in this phase.
- In this phase, callbacks triggered via
- Special system events are run in this phase, like when a net.Socket TCP soccer throws an
Apart from these, there are two special microtask queues that can have callbacks added to them while a phase is running.
The first microtask queue handles callbacks registered using
The second microtask queues handles
promisesthat reject or resolve.
Callback in the microtask queues take priority over callbacks in the phase's normal queue.
Callbacks in the next tick microtask queue run before callbacks in the promise microtask queue.
When the application starts running, the event loop is also started and the phases are handled one at a time. Node.js adds callbacks to different queues as appropriate while the application runs
When the event loop gets to a phase, it will run all the callbacks in the phase's queue. Once all the callbacks in a given phase are executed, the event loop then moves on to the next phase.
Let's see one code example:
Output will be : 8, 3, 2, 1, 4, 7, 6, 5
Let's see what is happening behind the scene:
Code execution starts off executing line by line in the poll phase.
First, the fs module is required.
setImmediate()call is run and its callback is added to the
promiseresolves, adding callback to the
promise microtask queue.
process.nextTick()runs next, adding its callback to the
next tick microtask queue.
fs.readFile()tells Node.js to start reading the file, placing its callback in the
poll queueonce it is ready.
console.log(8)is called and 8 is printed to the screen.
That's it for the current stack.
Now, the two microtask queues are consulted. The next tick microtask queue is always checked first, and callback 3 is called. Since, there is only one callback in the next tick microtask queue, the promise microtask queue is checked next and callback 2 is executed. That finished the two micro-task queues and the current poll phase is completed.
Now, the event loop enters the check phase. This phase has callback 1 in it, which is then executed. Both the microtask queues are empty at this point, so the check phase ends.
The close phase is checked next but is empty, so the loop continues. The same happens with the timers phase and the pending phase, and the event loop continues back around to the poll phase.
Once it is back in the poll phase, the application doesn't have much else going on, so it basically waits until the file has finished being read. Once that happens, the
fs.readFile() callback is run.
The number 4 is immediately printed since it's the first line in the callback.
setTimeout()call is made and callback 5 is added to the timers queue.
setImmediate()call happens next, adding callback 6 to the check queue.
Finally, the process.nextTick() call is made, adding callback 7 to the next ticket microtask queue.
The poll phase is now finished and the microtask queues are again consulted.
- Callback 7 runs from the next tick queue,
the promise queue is consulted and found empty, and the poll phase ends.
Again the event loop enters to the check phase where callback 6 is encountered. The number is printed and microtask queues are determined to be empty and the phase ends.
The close phase is checked again and found empty.
Finally, the timers phase is consulted and callback 5 is executed and prints 5 on the console.
Once that's done, the applications doesn't have any more work to do and it exits.
As we know, Node.js runtime environment is single-threaded. Running too much code in a single stack will stall the event loop and prevent other callbacks from firing. To prevent this event loop starving situation, you can break your CPU-heavy operations up across multiple stacks. For example, if you are processing 1000 data records, you can consider breaking down into 10 batches of 100 records, using
setImmediate() at the end of each batch to continue processing the next batch. Another option is forking a new child process and offload processing to it. But never break up such work using
process.nextTick(). Doing so will lead to a microtask queue that never empties and your application will be trapped in the same phase forever. The runtime won't throw any error instead it will remain a zombie process that eats through CPU.
That's all about event loop.
I hope you have enjoyed reading this article and found it interesting and useful :)
Thanks and see you later !
- Distributed Systems with Node.js (Book)