π Behind the Scenes of Asynchronous JavaScript: The Event Loop in Depth

We often hear that the event loop is responsible for handling asynchronous code in JavaScript. While thatβs true, today let's dive deeper into how the event loop works internally, especially in the context of Node.js.
π Overview
The event loop manages the execution of asynchronous operations. Whenever asynchronous code (e.g., setTimeout
, fs.readFile
, fetch
, etc.) is encountered, the work is offloaded:
To the browserβs Web APIs (in the case of frontend JavaScript), or
To libuv, the underlying library in Node.js that handles asynchronous I/O operations.
Once the synchronous code in the call stack finishes execution, the event loop checks if any asynchronous tasks are ready. If they are, it pushes their corresponding callback functions onto the call stack for execution.
This is the high-level idea, but under the hood, the event loop has multiple phases, each handling specific types of callbacks.
π Event Loop Phases in Node.js
The event loop in Node.js has 4 main phases:
1. Timers Phase
Handles callbacks from:
setTimeout
setInterval
Note: The timers are not guaranteed to execute exactly after the specified delay. They are queued and executed in this phase only when the stack is clear and the delay has elapsed.
2. Poll Phase
Handles:
- I/O callbacks such as
fs.readFile
,net
,http
,https
,crypto
, etc.
This is a crucial phase. If the poll queue is empty:
And there are no timers due, the event loop waits here for callbacks.
If timers are due, it skips the waiting and proceeds directly to the timers phase in the next tick.
3. Check Phase
Handles:
setImmediate
callbacks
Callbacks scheduled using setImmediate()
are executed after the poll phase, giving them a higher priority than timers in some cases.
4. Close Callbacks Phase
Handles:
- Callbacks like
socket.on('close', ...)
orprocess.on('exit', ...)
βοΈ Microtasks: The Internal Cycle
Between the main phases of the event loop, there is an internal microtask queue, consisting of two key queues:
1. process.nextTick()
Queue
Handled before any other microtask
Has higher priority than Promises
2. Promise Queue
Handles:
Promise.then()
async/await
fetch
(on the client side)
Before entering each main phase (Timers, Poll, etc.), Node.js first drains the process.nextTick()
queue, followed by the Promise queue. This means these microtasks are executed between each phase of the event loop.
β οΈ Starvation Risk
Using process.nextTick()
extensively can lead to starvation, where the event loop never proceeds to the next phase. This happens if:
A
process.nextTick()
callback schedules anotherprocess.nextTick()
Or deeply nested Promises keep scheduling more microtasks
This can prevent I/O callbacks or timers from executing, leading to unresponsive behavior.
Recommendation: Use process.nextTick()
sparingly and prefer setImmediate()
or Promises when possible.
π§ Interesting Node.js Behavior
Once all synchronous and asynchronous code is executed, the event loop enters the Poll phase and waits for new I/O events.
If a callback is ready during this wait (e.g., from a file read), the event loop doesn't restart from the Timers phase. Instead, it continues directly from the Poll phase, where it was waiting.
β Summary
Phase | Handles |
Timers | setTimeout , setInterval |
Poll | I/O operations (fs , net , http , etc.) |
Check | setImmediate |
Close | Close events (socket.on('close') ) |
Microtasks (between phases):
process.nextTick()
(highest priority)Promises (
then
,async/await
)
Understanding the internals of the event loop can help you write better, more predictable, and performant Node.js applications. I hope this gave you a clearer view of what happens behind the scenes!
π§ͺ Code Executions and Their Outputs
Letβs look at a few examples that demonstrate how different asynchronous APIs are scheduled and executed by the event loop.
π Code Sample 1
const a = 100;
setImmediate(() => console.log("setImmediate"));
fs.readFile("./file.txt", "utf8", () => {
console.log("File Reading CB");
});
setTimeout(() => console.log("Timer expired"), 0);
function printA() {
console.log("a=", a);
}
printA();
console.log("Last line of the file.");
Output:
a= 100
Last line of the file.
Timer expired
setImmediate
File Reading CB
π Code Sample 2
const a = 100;
setImmediate(() => console.log("setImmediate"));
Promise.resolve().then(() => console.log("Promise"));
fs.readFile("./file.txt", "utf8", () => {
console.log("File Reading CB");
});
setTimeout(() => console.log("Timer expired"), 0);
process.nextTick(() => console.log("process.nextTick"));
function printA() {
console.log("a=", a);
}
printA();
console.log("Last line of the file.");
Output:
a= 100
Last line of the file.
process.nextTick
Promise
Timer expired
setImmediate
File Reading CB
π Code Sample 3
setImmediate(() => console.log("setImmediate"));
setTimeout(() => console.log("Timer expired"), 0);
Promise.resolve().then(() => console.log("Promise"));
fs.readFile("./file.txt", "utf8", () => {
setTimeout(() => console.log("2nd timer"), 0);
process.nextTick(() => console.log("2nd nextTick"));
setImmediate(() => console.log("2nd setImmediate"));
console.log("File Reading CB");
});
process.nextTick(() => console.log("nextTick"));
console.log("Last line of the file.");
Output:
Last line of the file.
nextTick
Promise
Timer expired
setImmediate
File Reading CB
2nd nextTick
2nd setImmediate
2nd timer
π Code Sample 4
setImmediate(() => console.log("setImmediate"));
setTimeout(() => console.log("Timer expired"), 0);
Promise.resolve().then(() => console.log("Promise"));
fs.readFile("./file.txt", "utf8", () => {
console.log("File Reading CB");
});
process.nextTick(() => {
process.nextTick(() => console.log("inner nextTick"));
console.log("nextTick");
});
console.log("Last line of the file.");
Output:
Last line of the file.
nextTick
inner nextTick
Promise
Timer expired
setImmediate
File Reading CB
Subscribe to my newsletter
Read articles from Gagan BN directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
