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 setImmediate
1const 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.nextTick
1function 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.setImmediate
1function 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.
setTimeout
1function 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.setTimeout
1function 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.nextTick
1function 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.