Node.js Event-Loop Mechanism

I’m learning the mechanism of Event-Loop in Node.js, and I’m doing some exercises, but have some confusions as explained bellow.

const fs = require("fs");

setTimeout(() => console.log("Timer 1"), 0);
setImmediate(() => console.log("Immediate 1"));

fs.readFile("test-file-with-1-million-lines.txt", () => {
  console.log("I/O");

  setTimeout(() => console.log("Timer 2"), 0);
  setTimeout(() => console.log("Timer 3"), 3000);
  setImmediate(() => console.log("Immediate 2"));
});

console.log("Hello");

I expected to see the following output:

Hello
Timer 1
Immediate 1
I/O
Timer 2
Immediate 2
Timer 3

but I get the following output:

Hello
Timer 1
Immediate 1
I/O
Immediate 2
Timer 2
Timer 3

Would you please clarify for me how are these lines executed step by step.

Answer

First off, I should mention that if you really want asynchronous operation A to be processed in a specific order with relation to asynchronous operation B, you should probably write your code such that it guarantees that without relying on the details of exactly what gets to run first. But, that said, I have run into issues where one type of asynchronous operation can “hog” the event loop and starve other types of events and it can be useful to understand what’s really going on inside if/when that happens.

Broken down to its core, your question is really about why Immediate2 logs before Timer2 when scheduled from within an I/O callback, but not when called from top level code? Thus it is inconsistent.

This has to do with where the event loop is in its cycle through various checks it is doing when the setTimeout() and setImmediate() are called (when they are scheduled). It is somewhat explained here: https://nodejs.org/en/docs/guides/event-loop-timers-and-nexttick/#setimmediate-vs-settimeout.

If you look at this somewhat simplified diagram of the event loop (from the above article):

enter image description here

You can see that there are a number of different parts to the event loop cycle. setTimeout() is served by the “timers” block at the top of the diagram. setImmediate() is served in the “check” block near the bottom of the diagram. File I/O is served in the “poll” block in the middle.

So, if you schedule both a setImmediate(fn1) and a setTimeout(fn2, 0) from within a file I/O callback (which is your case for Intermediate2 and Timer2), then the event loop processing happens to be in the poll phase when these two are scheduled. So, the next phase of the event loop is the “check” phase and the setImmediate(fn1) gets processed. Then, after the “check” phase and the “close callbacks” phase, then it cycles back around to the “timers” phase and you get the setTimeout(fn2,0).

If, on the other hand, you call those same two setImmediate() and setTimeout() from code that runs from a different phase of the event loop, then the timer might get processed first before the setImmediate() – it will depend upon exactly where that code was executed from in the event loop cycle.

This structure of the event loop is why some people describe setImmediate() as “it runs right after I/O” because it’s positioned in the loop to be processed right after the “poll” phase. If you are in the middle of processing some file I/O in an I/O callback and you want something to run as soon as the stack unwinds, you can use setImmediate() to accomplish that. It will always run after the current I/O callback finishes, but before timers.

Note: Missing from this simplified description is promises which have their own special treatment. Promises are considered microtasks and they have their own queue. They get to run a lot more often. Starting with node v11, they get to run in every phase of the event loop. So, if you have three pending timers that are ready to run and you get to the timer phase of the event loop and call the callback for the first pending timer and in that timer callback, you resolve a promise, then as soon as that timer callback returns back to the system, then it will serve that resolved promise. So, microtasks (such as promises and process.nextTick()) get served (if waiting to run) between every operation in the event loop, not just between phases of the event loop, but even between pending events in the same phase. You can read more about these specifics and the changes in node v11 here: New Changes to the Timers and Microtasks in Node v11.0.0 and above.

I believe this was done to improve the performance of promise-related code as promises became more of a central part of the nodejs architecture for asynchronous operations and there is also some standards-related work in this area too to make this consistent across different JS envrionments.

Here’s another reference that covers part of this:

Nodejs Event Loop – interaction with top-level code

Leave a Reply

Your email address will not be published. Required fields are marked *