Understanding Event Loop and Call Stack in JavaScript & Node.js

Understanding Event Loop and Call Stack in JavaScript & Node.js

October 1, 2024

loop eventnodejscall stack

JavaScript is known for its single-threaded nature, meaning it can only execute one operation at a time in its execution context. However, thanks to asynchronous programming, JavaScript can handle multiple tasks efficiently without getting blocked, especially when dealing with I/O operations like file reads, network requests, or timers. The key concepts that make this possible are the Call Stack, Event Loop, and Task Queue (or Job Queue in the case of Promises).

In this article, we’ll dive deep into how these mechanisms work, how they interact, and why they are important to understand for both JavaScript and Node.js.

async.jpg

The Call Stack: Where Synchronous Code Lives

The Call Stack is a Last In, First Out (LIFO) data structure used by JavaScript to keep track of the execution context. Whenever a function is invoked, it is placed on the top of the stack. When the function finishes execution, it is removed from the stack.

nodejs-runtime-env.webp

Example of Call Stack in Action

function first() {
  console.log("First");
}

function second() {
  first();
  console.log("Second");
}

function third() {
  second();
  console.log("Third");
}

third();
  • When third() is called, it is pushed onto the stack.
  • Inside third(), second() is invoked, so second() gets pushed on top of third().
  • Then, first() is called from second(), so it gets added to the stack.
  • Once first() finishes, it is popped off, then second() finishes and is removed, followed by third().

The execution flow looks like this:

Stack:
1. first()  -> executes, pops off.
2. second() -> executes, pops off.
3. third()  -> executes, pops off.

Call Stack Key Points:

  • Synchronous code runs in the order it appears.
  • If a function takes too long (e.g., a large loop or a blocking operation), the stack gets "stuck" until it finishes, halting further execution.

The Event Loop: The Heart of Asynchronous Programming

The Event Loop allows JavaScript to perform non-blocking I/O operations, despite being single-threaded. It continuously monitors the Call Stack and the Task Queue (also known as the Callback Queue).

When the Call Stack is empty, the Event Loop pushes tasks from the Task Queue into the stack for execution.

Example of Event Loop and Asynchronous Code

console.log("Start");

setTimeout(() => {
  console.log("Inside setTimeout");
}, 1000);

console.log("End");

Output:

Start
End
Inside setTimeout

What happens under the hood?

  1. "Start" is logged immediately since it is synchronous code.
  2. The setTimeout() function is called. This is an asynchronous operation, so it registers a callback function to be executed after 1000ms, but the function itself is non-blocking and is removed from the Call Stack almost immediately.
  3. "End" is logged because the stack is empty again, and this is synchronous.
  4. After the delay (1000ms), the callback from setTimeout is placed in the Task Queue.
  5. The Event Loop checks if the Call Stack is empty and, finding it so, pushes the callback from the Task Queue onto the Call Stack for execution, printing "Inside setTimeout".

Event Loop Key Points:

  • The Event Loop ensures that asynchronous operations don't block the main thread.
  • Callbacks from async operations are only executed after the Call Stack is clear.

Task Queue and Microtask Queue: Different Priorities

In JavaScript, there are two important queues:

  • Task Queue (or Callback Queue): Holds callbacks from events like setTimeout, setInterval, or I/O operations.
  • Microtask Queue: Handles tasks like Promises and process.nextTick() (in Node.js), and they are given higher priority.

loop-event.gif

Example with Promises (Microtasks)

console.log("Start");

setTimeout(() => {
  console.log("Timeout");
}, 0);

Promise.resolve().then(() => {
  console.log("Promise");
});

console.log("End");

Output:

Start
End
Promise
Timeout

What happens here?

  1. "Start" is logged.
  2. The setTimeout() is called with a delay of 0ms, but its callback still goes to the Task Queue.
  3. A Promise is resolved, and its .then() callback is placed in the Microtask Queue.
  4. "End" is logged synchronously.
  5. The Event Loop checks the Microtask Queue first, so "Promise" is logged before "Timeout" (even though both are asynchronous).

Microtask vs. Task:

  • Microtasks (Promises, process.nextTick()) are prioritized and executed before tasks in the Task Queue.
  • This means the Microtask Queue is always checked before the Event Loop handles tasks in the Task Queue.

Node.js and the Event Loop

While JavaScript in the browser primarily deals with user events and rendering, Node.js operates in a server environment where it handles I/O operations like file reads, database access, and network requests. Node.js uses the same Event Loop, but with additional layers like the libuv library that manages its I/O operations asynchronously.

nodejs.webp

Node.js Event Loop Phases:

The Node.js Event Loop has several phases:

  1. Timers: Executes callbacks for setTimeout() and setInterval().
  2. Pending Callbacks: Executes I/O callbacks.
  3. Idle/Prepare: Internal use only.
  4. Poll: Retrieves new I/O events, executing I/O-related callbacks.
  5. Check: Executes callbacks for setImmediate().
  6. Close Callbacks: Handles cleanup callbacks (e.g., socket.on('close')).

event-loop.webp

Example in Node.js

const fs = require('fs');

console.log("Start");

fs.readFile('example.txt', (err, data) => {
  if (err) throw err;
  console.log("File read");
});

setTimeout(() => {
  console.log("Timeout");
}, 0);

console.log("End");

Output:

Start
End
File read
Timeout

What happens here?

  1. "Start" is logged synchronously.
  2. The fs.readFile initiates an asynchronous file read, and its callback is placed in the Poll phase of the Event Loop.
  3. A setTimeout with 0ms delay is placed in the Timer phase.
  4. "End" is logged synchronously.
  5. When the file read completes, the callback in the Poll phase is executed.
  6. Finally, the setTimeout callback from the Timer phase is executed.

Key Points:

  • Node.js manages I/O operations asynchronously with help from libuv and the Event Loop.
  • I/O callbacks are processed during the Poll phase, and setTimeout callbacks are processed in the Timers phase.

Conclusion

Understanding how the Event Loop and Call Stack work is critical for writing efficient, non-blocking JavaScript and Node.js applications. By leveraging asynchronous programming, JavaScript can handle multiple tasks without freezing the main thread, making it ideal for both frontend and backend development.

Recap:

  • The Call Stack executes synchronous code, while the Event Loop handles asynchronous tasks.
  • Tasks are placed in either the Task Queue or Microtask Queue, with microtasks having higher priority.
  • Node.js extends the Event Loop with additional phases to handle I/O operations.

With this knowledge, you can better optimize your JavaScript and Node.js code, improving performance and responsiveness.


This blog post provides a detailed yet accessible explanation of how the Event Loop and Call Stack work together in both JavaScript and Node.js. Let me know if you'd like to tweak or expand any section further!