Dec 29, 2024

Understanding the Node.js Event Loop

Introduction

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.

The Node.js Process and Threads

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:

  1. The main thread runs JavaScript code.
  2. Threads in the C++ part (libuv) handle tasks like file writing (fs.write) or network requests asynchronously.

This architecture allows Node.js to achieve concurrency, even though JavaScript itself is single-threaded.

What Happens During Asynchronous Operations?

When an async operation like fs.write is encountered, the main thread:

  1. Immediately schedules the task in the C++ layer (libuv) and continues execution.
  2. Delegates the task to other threads, allowing parallel work.
Example: Writing a File
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 Three Phases of the Node.js Event Loop

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.

1. Timer Phase

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");
output $

Start

Timer callback executed

2. Microtasks Phase

Before transitioning between phases, Node.js processes all microtasks in the microtask queue. Microtasks include:

Microtasks 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");
output $

Start

End

Next tick

Promise resolved

3. Check Phase

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");
output $

Start

Timeout executed

SetImmediate executed

Understanding the Order of Execution

Node.js follows a strict order when executing tasks:

  1. All synchronous code runs first.
  2. Microtasks (e.g., process.nextTick, resolved promises) are executed.
  3. Timer callbacks are processed.
  4. I/O callbacks (e.g., file operations) are executed.
  5. setImmediate callbacks are handled in the check phase.

By understanding these phases, developers can write more predictable and efficient asynchronous code.

Example 1: Event Listeners, Timers, and Microtasks

Consider the following code snippet:

1const name = "joe";
2
3 fs.writeFile("example.txt", "Hello, Joe!", () => {
4 console.log("File write completed!");
5 });
6
7 app.on("request", () => {
8 console.log("Request received!");
9 });
10
11 setTimeout(() => {
12 console.log("Timeout completed!");
13 }, 0);
14
15 process.nextTick(() => {
16 console.log("Next tick executed!");
17 });

Explanation

  1. fs.writeFile schedules a file write operation in the thread pool and immediately moves to the next task.
  2. app.on("request") sets up a persistent event listener that waits for incoming requests but does not block execution.
  3. setTimeout schedules a callback to execute after the timer expires.
  4. process.nextTick adds a callback to the microtask queue for immediate execution after the current operation.

When this code runs, the execution order is:

  1. The synchronous code completes.
  2. process.nextTick executes first, as it is part of the microtask queue.
  3. The timer and event listeners execute based on the Event Loop phases.
Example 2: Order of Execution with Various Callbacks
1const start = () => console.log("1- start");
2
3 const end = () => console.log("1- end");
4
5 start();
6
7 process.nextTick(() => console.log("2- first nextTick callback"));
8
9 setTimeout(() => console.log("3- setTimeout callback"), 0); // could happen before or after setImmediate
10
11 setImmediate(() => console.log("3- setImmediate callback"));
12
13 process.nextTick(() => console.log("2- second nextTick callback"));
14
15 Promise.resolve().then(() => console.log("2- promise callback"));
16
17 process.nextTick(() => console.log("2- third nextTick callback"));
18
19 end();
output $

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

  1. Synchronous code execution:
    • start() logs "1- start".
    • end() logs "1- end" after all immediate synchronous code.
  2. Microtasks (process.nextTick and Promises):
    • process.nextTick callbacks are executed in the order they are scheduled.
    • The Promise.resolve callback executes after all process.nextTick callbacks.
  3. Timers and Immediate:
    • The setTimeout callback and setImmediate are scheduled in different phases of the Event Loop. The order of their execution depends on the environment.
Example 3: Complex Timer and Immediate Interactions
1setTimeout(() => console.log("A"), 0);
2
3 setTimeout(() => {
4 setImmediate(() => console.log("D"));
5 process.nextTick(() => console.log("B"));
6 }, 0);
7
8 setTimeout(() => console.log("C"), 0);
output $

A

C

B

D

Explanation

  1. First Timer Phase:
    • The first setTimeout logs "A".
    • The second setTimeout schedules two tasks: a process.nextTick callback to log "B" and a setImmediate callback to log "D".
    • The third setTimeout logs "C".
  2. Next Tick Queue:
    • Before moving to the next phase, the process.nextTick logs "B".
  3. Check Phase:
    • The setImmediate callback logs "D".
Example 4: Event Loop Utilization and Setup
1const { performance } = require("perf_hooks");
2
3 // 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 }
output $

{ 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");
2
3 // 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 });
output $

{ 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");
2
3 // 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 });
output $

{ 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.

Timer and Immediate Interactions
Exploring Timer and Immediate Interactions

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);
2
3 setTimeout(() => {
4 setImmediate(() => console.log("D"));
5 process.nextTick(() => console.log("B"));
6 }, 0);
7
8 setTimeout(() => console.log("C"), 0);

Depending on how Node.js schedules tasks, the output can vary:

output $

A

B

C

D

output $

A

B

D

C

Execution Breakdown

  1. Timer Phase:

    • All setTimeout callbacks are scheduled in the timer queue with a 0ms delay.
    • The first setTimeout logs "A".
    • The second setTimeout schedules two additional tasks:
      • A process.nextTick callback to log "B" (microtask).
      • A setImmediate callback to log "D" (check phase).
    • The third setTimeout logs "C".
  2. Microtasks Phase:

    • The process.nextTick callback logs "B" before the Event Loop continues to the next phase.
  3. Check Phase:

    • The setImmediate callback logs "D" after all timer callbacks have been processed.

Detailed Explanation

Understanding the variability in outputs helps in debugging and writing predictable, environment-independent Node.js applications.

Explaining fs.readFile with setTimeout and setImmediate
Code:
1const fs = require("node:fs");
2
3 fs.readFile(__filename, () => {
4 setTimeout(() => console.log("B"), 0);
5
6 setImmediate(() => console.log("A"));
7 });
output $

A

B

Explanation

  1. File System Operation (Poll Phase)

    • The 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.
  2. Callback Logic

    • Inside the fs.readFile callback
      • A setTimeout is scheduled with a 0ms delay, placing its callback (console.log("B")) into the timer phase queue.
      • A setImmediate is scheduled, placing its callback (console.log("A")) into the check phase queue.
  3. Event Loop Behavior

    • When the Event Loop transitions to the poll phase, the callback for fs.readFile is executed.
    • Within this callback, the following happens
      • setTimeout schedules "B" for the next timer phase.
      • setImmediate schedules "A" for the current check phase.
    • After executing the fs.readFile callback
      • The Event Loop moves to the check phase and processes the setImmediate callback, logging "A".
      • After the check phase, the Event Loop transitions to the timer phase and processes the setTimeout callback, logging "B".
  4. Guaranteed Execution Order

    • In this specific context, "A" will always log before "B" because
      • setImmediate callbacks are processed in the check phase, which occurs after the poll phase but before the timer phase.
      • The setTimeout callback is processed in the timer phase, which comes after the check phase.

Key Takeaways

Recursive Function Behavior in the Event Loop
Example 1: Recursive Call Without Async Mechanism
1function bar() {
2 console.log("bar");
3 return bar();
4 }
5 bar();

Explanation

  1. This code represents a recursive function that calls itself without any asynchronous mechanism.
  2. Each call to bar adds a new frame to the call stack.
  3. The recursive calls continue until the call stack limit is exceeded, resulting in a RangeError: Maximum call stack size exceeded.
  4. The program crashes due to stack overflow.
  5. Without asynchronous mechanisms, recursive functions can exhaust the stack, especially in cases with infinite recursion.
Example 2: Recursive Call Using process.nextTick
1function bar() {
2 console.log("bar");
3 return process.nextTick(bar);
4 }
5 bar();

Explanation

  1. The process.nextTick mechanism schedules the recursive call in the microtask queue.
  2. After the current execution completes, the Event Loop processes the microtask queue, allowing the next invocation of bar.
  3. The stack is not overwhelmed because each recursive call is placed in the microtask queue and executed after the current execution cycle.
  4. The CPU is used at 100%, but the memory remains stable as the stack is not continuously growing.
  5. Using 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.
Example 3: Recursive Call Using setImmediate
1function bar() {
2 console.log("bar");
3 return setImmediate(() => {
4 bar();
5 });
6 }
7 bar()

Explanation

  1. The setImmediate mechanism schedules the recursive call in the check phase of the Event Loop.

  2. Each invocation of bar is queued and executed in subsequent iterations of the Event Loop.

  3. The stack remains stable as each recursive call is queued asynchronously.

  4. Unlike process.nextTick, setImmediate involves the Event Loop phases, resulting in lower CPU usage (~30% instead of 100%).

  5. 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.

Fetch Data with Async/Await and setTimeout
1function fetchData() {
2 return new Promise((resolve, reject) => {
3 setTimeout(() => {
4 resolve("Data fetched");
5 }, 3000);
6 });
7 }
8
9 setTimeout(() => {
10 console.log("Hey");
11 }, 1000);
12
13 (async () => {
14 try {
15 const data = await fetchData();
16 console.log(data);
17 } catch (error) {
18 console.error(error);
19 }
20 })();
output $

Hey

Data fetched

Explanation

  1. Code Behavior

    • A fetchData function returns a Promise that resolves with "Data fetched" after 3 seconds using setTimeout.
    • Another setTimeout is scheduled to log "Hey" after 1 second.
    • An async IIFE (Immediately Invoked Function Expression) awaits the resolution of the fetchData promise and logs the resolved data.
  2. Execution Order

    • The setTimeout to log "Hey" is scheduled and will execute after 1 second (Timer Phase).
    • The fetchData function schedules a Promise to resolve after 3 seconds.
    • The async function pauses execution at the await until the promise resolves.
  3. Event Loop Phases

    • After 1 second, the setTimeout callback logs "Hey" during the Timer Phase.
    • After 3 seconds, the fetchData promise resolves, adding its then callback to the microtask queue.
    • The await resumes execution, logging "Data fetched".

Key Takeaways:

Await Explanation

Example 2: Nested Async/Await and setTimeout
1function fetchData() {
2 return new Promise((resolve, reject) => {
3 setTimeout(() => {
4 resolve("Data fetched");
5 }, 3000);
6 });
7 }
8
9 setTimeout(() => {
10 console.log("Hey");
11 }, 1000);
12
13 (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 })();
output $

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.

Example 3: CPU-Blocking Promise
1function fetchData() {
2 return new Promise((resolve, reject) => {
3 for(let i=0; i<=1e10; i++ ){}
4 resolve("Data fetched");
5 });
6 }
7
8 setTimeout(() => {
9 console.log("Hey");
10 }, 1000);
11
12 (async () => {
13 try {
14 const data = await fetchData();
15 console.log(data);
16 } catch (error) {
17 console.error(error);
18 }
19 })();
output $

Data fetched

Hey

Explanation

  1. Behavior

    • The fetchData function contains a heavy synchronous loop (for loop up to 1e10) inside the Promise constructor.
    • While the Promise resolves asynchronously, the loop itself blocks the main thread.
  2. Execution Impact

    • Important: Using Promise or async/await does not automatically make the code asynchronous. The for loop executes synchronously, blocking the Event Loop until completion.
    • During this time, other scheduled callbacks, such as the setTimeout callback, cannot execute.
  3. Execution Order

    • The blocking loop in fetchData halts the main thread until it finishes.
    • Once the loop completes, the Promise resolves, and the await continues.
    • Only after the fetchData function completes will the setTimeout callback execute.

Key Takeaways:

Code 7: CPU-Blocking Promise with process.nextTick
1function fetchData() {
2return new Promise((resolve, reject) => {
3 for(let i=0; i<=1e10; i++ ){}
4 resolve("Data fetched");
5});
6}
7
8process.nextTick(() => {
9console.log("Hey");
10});
11
12(async () => {
13try {
14const data = await fetchData();
15console.log(data);
16} catch (error) {
17console.error(error);
18}
19})();
output $

Hey

Data fetched

Explanation

  1. Behavior

    • The 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.
  2. Execution Impact

    • The blocking for loop prevents process.nextTick from executing immediately.
    • Once the loop completes, the process.nextTick callback executes, followed by the continuation of the async function.
  3. Execution Order

    • The main thread processes the blocking loop in fetchData first.
    • After the loop, process.nextTick logs "Hey".
    • Finally, the resolved data from fetchData is logged.

Key Takeaways

contact

padhysoumya98@gmail.com

Soumya

2025