Node.js has a fascinating architecture that enables developers to write highly scalable, non-blocking code. One of the core components enabling this functionality is the Event Loop. In this blog, we’ll dive into its inner workings, covering concepts like asynchronous work, microtasks, and the event loop phases.
When a Node.js application runs, it operates within a process that uses a main thread. However, Node.js also interacts with additional threads for specific operations, such as file system tasks, network requests, and timers. These threads come from libraries like libuv, zlib, and OpenSSL.
For example:
fs.write) or network requests asynchronously.This architecture allows Node.js to achieve concurrency, even though JavaScript itself is single-threaded.
When an async operation like fs.write is encountered, the main thread:
1fs.writeFile('example.txt', 'Hello, Node!', () => {2 console.log('File written!');3 });
Here, the fs.writeFile function:
This mechanism is the foundation of non-blocking I/O.
The Event Loop is at the core of Node.js’s non-blocking I/O model, enabling it to efficiently handle asynchronous tasks. Let’s break down its three main phases to understand how it orchestrates task execution.
The timer phase handles callbacks scheduled by setTimeout and setInterval. When the Event Loop enters this phase:
Example:
1setTimeout(() => console.log("Timer callback executed"), 1000);2 console.log("Start");
Start
Timer callback executed
Before transitioning between phases, Node.js processes all microtasks in the microtask queue. Microtasks include:
process.nextTick callbacksPromise handlersMicrotasks always run immediately after the current operation completes and before the Event Loop moves to the next phase.
Example:
1console.log("Start");2 process.nextTick(() => console.log("Next tick"));3 Promise.resolve().then(() => console.log("Promise resolved"));4 console.log("End");
Start
End
Next tick
Promise resolved
The check phase handles setImmediate callbacks. These callbacks are executed after the I/O events phase but before returning to the timer phase in the next loop iteration.
Example:
1setImmediate(() => console.log("SetImmediate executed"));2 setTimeout(() => console.log("Timeout executed"), 0);3 console.log("Start");
Start
Timeout executed
SetImmediate executed
Node.js follows a strict order when executing tasks:
process.nextTick, resolved promises) are executed.setImmediate callbacks are handled in the check phase.By understanding these phases, developers can write more predictable and efficient asynchronous code.
Consider the following code snippet:
1const name = "joe";23 fs.writeFile("example.txt", "Hello, Joe!", () => {4 console.log("File write completed!");5 });67 app.on("request", () => {8 console.log("Request received!");9 });1011 setTimeout(() => {12 console.log("Timeout completed!");13 }, 0);1415 process.nextTick(() => {16 console.log("Next tick executed!");17 });
Explanation
fs.writeFile schedules a file write operation in the thread pool and immediately moves to the next task.app.on("request") sets up a persistent event listener that waits for incoming requests but does not block execution.setTimeout schedules a callback to execute after the timer expires.process.nextTick adds a callback to the microtask queue for immediate execution after the current operation.When this code runs, the execution order is:
process.nextTick executes first, as it is part of the microtask queue.1const start = () => console.log("1- start");23 const end = () => console.log("1- end");45 start();67 process.nextTick(() => console.log("2- first nextTick callback"));89 setTimeout(() => console.log("3- setTimeout callback"), 0); // could happen before or after setImmediate1011 setImmediate(() => console.log("3- setImmediate callback"));1213 process.nextTick(() => console.log("2- second nextTick callback"));1415 Promise.resolve().then(() => console.log("2- promise callback"));1617 process.nextTick(() => console.log("2- third nextTick callback"));1819 end();
1- start
1- end
2- first nextTick callback
2- second nextTick callback
2- third nextTick callback
2- promise callback
3- setTimeout callback
3- setImmediate callback
Explanation
start() logs "1- start".end() logs "1- end" after all immediate synchronous code.process.nextTick callbacks are executed in the order they are scheduled.Promise.resolve callback executes after all process.nextTick callbacks.setTimeout callback and setImmediate are scheduled in different phases of the Event Loop. The order of their execution depends on the environment.1setTimeout(() => console.log("A"), 0);23 setTimeout(() => {4 setImmediate(() => console.log("D"));5 process.nextTick(() => console.log("B"));6 }, 0);78 setTimeout(() => console.log("C"), 0);
A
C
B
D
Explanation
setTimeout logs "A".setTimeout schedules two tasks: a process.nextTick callback to log "B" and a setImmediate callback to log "D".setTimeout logs "C".process.nextTick logs "B".setImmediate callback logs "D".1const { performance } = require("perf_hooks");23 // Process nextTick won't work!4 for (let i = 0; i < 10_000_000_000_000; i++) {5 if (i % 100_000_000 === 0) {6 console.log(performance.eventLoopUtilization());7 }8 }
{ idle: 0, active: 0, utilization: 0 }
{ idle: 0, active: 0, utilization: 0 }
{ idle: 0, active: 0, utilization: 0 }
Explanation
In this example, the loop occupies the main thread completely. The performance.eventLoopUtilization() method measures how much of the event loop’s capacity is being used. However, since the loop prevents the event loop from properly initializing, it doesn’t reflect realistic usage.
1const { performance } = require("perf_hooks");23 // Process nextTick won't work!4 process.nextTick(() => {5 for (let i = 0; i < 10_000_000_000_000; i++) {6 if (i % 100_000_000 === 0) {7 console.log(performance.eventLoopUtilization());8 }9 }10 });
{ idle: 0, active: 0, utilization: 0 }
{ idle: 0, active: 0, utilization: 0 }
{ idle: 0, active: 0, utilization: 0 }
Explanation
Here, process.nextTick places the task in the microtask queue, but the loop again blocks the main thread, preventing the event loop from running. The application never reaches the event loop setup stage.
1const { performance } = require("perf_hooks");23 // Process nextTick won't work!4 setImmediate(() => {5 for (let i = 0; i < 10_000_000_000_000; i++) {6 if (i % 100_000_000 === 0) {7 console.log(performance.eventLoopUtilization());8 }9 }10 });
{ idle: 0, active: 0.7038998603820801, utilization: 1 }
{ idle: 0, active: 606.8724999427795, utilization: 1 }
{ idle: 0, active: 1091.934199810028, utilization: 1 }
Explanation
By scheduling the task with setImmediate, it ensures the event loop is set up before the loop executes. As a result, performance.eventLoopUtilization() can correctly reflect how the event loop is being utilized.
Key Takeaway
The placement of a long-running task affects whether the event loop is properly initialized. Using setImmediate ensures the event loop is set up, while process.nextTick or blocking synchronous code prevents it from running effectively.
The Event Loop in Node.js orchestrates the execution of callbacks from different phases, including timers and immediates. Let’s analyze the following code to understand their interactions:
1setTimeout(() => console.log("A"), 0);23 setTimeout(() => {4 setImmediate(() => console.log("D"));5 process.nextTick(() => console.log("B"));6 }, 0);78 setTimeout(() => console.log("C"), 0);
Depending on how Node.js schedules tasks, the output can vary:
A
B
C
D
A
B
D
C
Execution Breakdown
Timer Phase:
setTimeout callbacks are scheduled in the timer queue with a 0ms delay.setTimeout logs "A".setTimeout schedules two additional tasks:
process.nextTick callback to log "B" (microtask).setImmediate callback to log "D" (check phase).setTimeout logs "C".Microtasks Phase:
process.nextTick callback logs "B" before the Event Loop continues to the next phase.Check Phase:
setImmediate callback logs "D" after all timer callbacks have been processed.Detailed Explanation
setTimeout callback is executed in the timer phase.process.nextTick, which runs before moving to the next Event Loop phase.setTimeout callback for "C" runs before the setImmediate callback for "D", and in others, setImmediate precedes the next setTimeout.Understanding the variability in outputs helps in debugging and writing predictable, environment-independent Node.js applications.
fs.readFile with setTimeout and setImmediate1const fs = require("node:fs");23 fs.readFile(__filename, () => {4 setTimeout(() => console.log("B"), 0);56 setImmediate(() => console.log("A"));7 });
A
B
Explanation
File System Operation (Poll Phase)
fs.readFile function is a Node.js I/O operation. When invoked, it schedules its callback to execute in the poll phase of the Event Loop after the file's contents are read.Callback Logic
fs.readFile callback
setTimeout is scheduled with a 0ms delay, placing its callback (console.log("B")) into the timer phase queue.setImmediate is scheduled, placing its callback (console.log("A")) into the check phase queue.Event Loop Behavior
fs.readFile is executed.setTimeout schedules "B" for the next timer phase.setImmediate schedules "A" for the current check phase.fs.readFile callback
setImmediate callback, logging "A".setTimeout callback, logging "B".Guaranteed Execution Order
setImmediate callbacks are processed in the check phase, which occurs after the poll phase but before the timer phase.setTimeout callback is processed in the timer phase, which comes after the check phase.Key Takeaways
setImmediate is processed in the check phase, making it ideal for callbacks that should execute after I/O operations.setTimeout (even with a 0ms delay) is processed in the timer phase, which happens in the next iteration of the Event Loop after the poll phase.1function bar() {2 console.log("bar");3 return bar();4 }5 bar();
Explanation
bar adds a new frame to the call stack.RangeError: Maximum call stack size exceeded.process.nextTick1function bar() {2 console.log("bar");3 return process.nextTick(bar);4 }5 bar();
Explanation
process.nextTick mechanism schedules the recursive call in the microtask queue.bar.process.nextTick avoids stack overflow but can lead to high CPU usage since it bypasses the Event Loop phases, running immediately after the current operation.setImmediate1function bar() {2 console.log("bar");3 return setImmediate(() => {4 bar();5 });6 }7 bar()
Explanation
The setImmediate mechanism schedules the recursive call in the check phase of the Event Loop.
Each invocation of bar is queued and executed in subsequent iterations of the Event Loop.
The stack remains stable as each recursive call is queued asynchronously.
Unlike process.nextTick, setImmediate involves the Event Loop phases, resulting in lower CPU usage (~30% instead of 100%).
Using setImmediate ensures that the recursive calls do not overwhelm the stack or the CPU, making it more efficient than process.nextTick for long-running recursive operations.
Understanding these differences helps in choosing the right mechanism based on the requirements of the operation and the system’s constraints.
setTimeout1function fetchData() {2 return new Promise((resolve, reject) => {3 setTimeout(() => {4 resolve("Data fetched");5 }, 3000);6 });7 }89 setTimeout(() => {10 console.log("Hey");11 }, 1000);1213 (async () => {14 try {15 const data = await fetchData();16 console.log(data);17 } catch (error) {18 console.error(error);19 }20 })();
Hey
Data fetched
Explanation
Code Behavior
fetchData function returns a Promise that resolves with "Data fetched" after 3 seconds using setTimeout.setTimeout is scheduled to log "Hey" after 1 second.fetchData promise and logs the resolved data.Execution Order
setTimeout to log "Hey" is scheduled and will execute after 1 second (Timer Phase).fetchData function schedules a Promise to resolve after 3 seconds.await until the promise resolves.Event Loop Phases
setTimeout callback logs "Hey" during the Timer Phase.fetchData promise resolves, adding its then callback to the microtask queue.await resumes execution, logging "Data fetched".Key Takeaways:
setTimeout callback executes during the Timer Phase, while the Promise resolution callback executes as a microtask.async/await is syntactic sugar over Promises, pausing execution within the async function but not blocking the main thread.Await Explanation
await, you are blocking the parent
function marked as async but not the entire thread. - All the code following
await within the async function will not execute until the Promise
resolves or rejects.async function remains
paused until the awaited operation completes, maintaining a clean flow without
blocking the Event Loop.setTimeout1function fetchData() {2 return new Promise((resolve, reject) => {3 setTimeout(() => {4 resolve("Data fetched");5 }, 3000);6 });7 }89 setTimeout(() => {10 console.log("Hey");11 }, 1000);1213 (async () => {14 try {15 const data = await fetchData();16 setTimeout(() => {17 console.log("Hey");18 }, 1000);19 console.log(data);20 } catch (error) {21 console.error(error);22 }23 })();
Hey
Data fetched
Hey
Explanation
This code demonstrates the nested use of setTimeout within an async function. The outer setTimeout logs "Hey" after 1 second, and the inner one logs another "Hey" 1 second after fetchData completes. The await pauses execution of the async function without blocking the main thread.
1function fetchData() {2 return new Promise((resolve, reject) => {3 for(let i=0; i<=1e10; i++ ){}4 resolve("Data fetched");5 });6 }78 setTimeout(() => {9 console.log("Hey");10 }, 1000);1112 (async () => {13 try {14 const data = await fetchData();15 console.log(data);16 } catch (error) {17 console.error(error);18 }19 })();
Data fetched
Hey
Explanation
Behavior
fetchData function contains a heavy synchronous loop (for loop up to 1e10) inside the Promise constructor.Promise resolves asynchronously, the loop itself blocks the main thread.Execution Impact
Promise or async/await does not automatically make the code asynchronous. The for loop executes synchronously, blocking the Event Loop until completion.setTimeout callback, cannot execute.Execution Order
fetchData halts the main thread until it finishes.Promise resolves, and the await continues.fetchData function completes will the setTimeout callback execute.Key Takeaways:
async/await only pauses execution within the async function but does not transform synchronous operations into asynchronous ones.Promise affects the entire Event Loop, delaying other callbacks.process.nextTick1function fetchData() {2return new Promise((resolve, reject) => {3 for(let i=0; i<=1e10; i++ ){}4 resolve("Data fetched");5});6}78process.nextTick(() => {9console.log("Hey");10});1112(async () => {13try {14const data = await fetchData();15console.log(data);16} catch (error) {17console.error(error);18}19})();
Hey
Data fetched
Explanation
Behavior
fetchData function contains a blocking synchronous for loop, which executes on the main thread.process.nextTick schedules a callback to run before moving to the next Event Loop phase.Execution Impact
for loop prevents process.nextTick from executing immediately.process.nextTick callback executes, followed by the continuation of the async function.Execution Order
fetchData first.process.nextTick logs "Hey".fetchData is logged.Key Takeaways
process.nextTick has higher priority than other Event
Loop phases but can still be delayed by synchronous operations.