DEV Community

DevCorner
DevCorner

Posted on

Understanding the Event Loop in Node.js: A Step-by-Step Guide

Introduction

Node.js is single-threaded, but it efficiently handles concurrency using its event-driven, non-blocking architecture. The event loop is at the heart of this mechanism, managing asynchronous operations such as I/O tasks, timers, and callbacks.

In this blog, we will break down:

  • The Call Stack
  • The Web APIs
  • The Microtask Queue
  • The Macro-task Queue (Callback Queue)
  • The Event Loop

By the end, you will understand how Node.js executes JavaScript asynchronously and why some callbacks execute before others.


1. Call Stack

The Call Stack follows the Last In, First Out (LIFO) principle and is responsible for executing JavaScript functions.

Example:

function greet() {
    console.log("Hello, World!");
}
greet();
Enter fullscreen mode Exit fullscreen mode

Execution Flow:

  1. greet() is pushed onto the call stack.
  2. console.log() inside greet() is pushed onto the stack.
  3. "Hello, World!" is printed.
  4. console.log() is popped off.
  5. greet() is popped off.

When the stack is empty, Node.js can execute asynchronous tasks from the queues.


2. Web APIs (Node.js APIs)

Asynchronous operations such as setTimeout, I/O tasks, and HTTP requests are delegated to Web APIs in the browser or Node.js APIs in the runtime.

Example:

console.log("Start");

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

console.log("End");
Enter fullscreen mode Exit fullscreen mode

Execution Flow:

  1. "Start" is logged.
  2. setTimeout() registers a callback in Web APIs.
  3. "End" is logged.
  4. The Event Loop moves the setTimeout() callback to the Callback Queue and executes it after the call stack is empty.

Output:

Start
End
Timeout Callback
Enter fullscreen mode Exit fullscreen mode

Even with 0ms, the timer callback executes after synchronous code because it is moved to the Callback Queue.


3. Micro-task Queue

The Micro-task Queue (or Job Queue) has higher priority than the Callback Queue (Macro-task Queue).

Includes:

  • Promises (.then(), .catch(), .finally())
  • process.nextTick() (Node.js only, even higher priority than Promises)

Example:

console.log("Start");

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

Promise.resolve().then(() => console.log("Promise"));
process.nextTick(() => console.log("process.nextTick"));

console.log("End");
Enter fullscreen mode Exit fullscreen mode

Execution Flow:

  1. "Start" is logged.
  2. setTimeout() moves to the Callback Queue.
  3. Promise.then() moves to the Micro-task Queue.
  4. process.nextTick() moves to the Micro-task Queue (higher priority than Promises).
  5. "End" is logged.
  6. Execute Microtasks first:
    • "process.nextTick" is printed.
    • "Promise" is printed.
  7. Execute Macro-tasks:
    • "setTimeout" is printed.

Output:

Start
End
process.nextTick
Promise
setTimeout
Enter fullscreen mode Exit fullscreen mode

4. Macro-task Queue (Callback Queue)

The Macro-task Queue stores callbacks from:

  • setTimeout()
  • setInterval()
  • setImmediate()
  • I/O tasks (fs.readFile, network requests, database queries)
  • MessageChannel callbacks

Example:

setTimeout(() => console.log("setTimeout"), 0);
setImmediate(() => console.log("setImmediate"));
Enter fullscreen mode Exit fullscreen mode

Depending on the execution context, either setTimeout or setImmediate might execute first.


5. The Event Loop: Execution Order

The Event Loop follows this order:

  1. Execute all synchronous code (Call Stack)
  2. Execute process.nextTick()
  3. Execute Microtasks (Promises, async/await)
  4. Execute Macro-tasks (setTimeout, I/O, setImmediate)
  5. Repeat the cycle

Complete Example:

console.log("Start");

setTimeout(() => console.log("setTimeout"), 0);
setImmediate(() => console.log("setImmediate"));

Promise.resolve().then(() => console.log("Promise"));
process.nextTick(() => console.log("process.nextTick"));

console.log("End");
Enter fullscreen mode Exit fullscreen mode

Execution Order:

  1. "Start" is printed.
  2. setTimeout() moves to Macro-task Queue.
  3. setImmediate() moves to Macro-task Queue.
  4. Promise.then() moves to Micro-task Queue.
  5. process.nextTick() moves to Micro-task Queue (higher priority than Promises).
  6. "End" is printed.
  7. Execute Microtasks first:
    • "process.nextTick" is printed.
    • "Promise" is printed.
  8. Execute Macro-tasks:
    • "setTimeout" is printed.
    • "setImmediate" is printed.

Final Output:

Start
End
process.nextTick
Promise
setTimeout
setImmediate
Enter fullscreen mode Exit fullscreen mode

6. Summary

Micro-tasks (Higher Priority)

process.nextTick() (Node.js-only, highest priority)
Promises (.then(), .catch(), .finally())
MutationObservers (Browser-only)

Macro-tasks (Lower Priority)

setTimeout(), setInterval()
setImmediate() (executes after I/O tasks)
I/O operations (fs.readFile, network requests)
MessageChannel callbacks

Execution Order:

  1. Synchronous Code (Call Stack)
  2. process.nextTick()
  3. Promises (Microtasks)
  4. Macro-tasks (setTimeout, setInterval, setImmediate, I/O tasks)

7. Conclusion

The Event Loop is the backbone of Node.js' asynchronous execution. Understanding it helps in:

  • Writing efficient, non-blocking code.
  • Avoiding callback hell.
  • Optimizing performance in high-concurrency applications.

Next Steps:

Try different combinations of async operations.
Use Promises and async/await for cleaner code.
Experiment with network and file system tasks in Node.js.

Would you like a visual representation or an interactive demo? Let me know!

Top comments (0)