libuv & Event Loop | node js

Episode 09 - libuv & Event Loop

Hey everyone! Welcome back to the Node.js tutorial series. You're diving into the core of Node.js's asynchronous handling with libuv!

Understanding how libuv manages the event loop, callback queues, and thread pools is crucial, especially for non-blocking I/O tasks.

What we will cover:

  • Event Loop in libuv
  • Callback Queue
  • Thread Pool
  • Four Phases of Event Loop
  • Microtasks (process.nextTick & Promises)
  • Output Questions & Explanations

The Event Loop - Heart of Node.js

The event loop in libuv is the heart of how Node.js handles asynchronous operations. It allows Node.js to perform non-blocking I/O operations, even though JavaScript is single-threaded.

Tasks that are offloaded to libuv include:

  • File system operations
  • DNS lookups
  • Network requests
  • And many more...

The Callback Queue

The callback queue is where callbacks are stored after an asynchronous operation is completed. The event loop processes this queue to execute the callbacks when the call stack is empty.

Callback Queue Flow:
====================

Async Operation Completes
        │
        ▼
┌─────────────────────┐
│   Callback Queue    │ ← Callback waits here
└──────────┬──────────┘
           │
    Is Call Stack Empty?
           │
     ┌─────┴─────┐
     │           │
    No          Yes
     │           │
   Wait      Push to Call Stack
     │           │
     └─────┬─────┘
           │
           ▼
       Execute!

The Thread Pool

The thread pool, on the other hand, is used for handling more time-consuming tasks that cannot be handled within the event loop without blocking it, such as:

  • File system operations
  • Cryptographic functions
  • DNS lookups
  • Compression

How libuv Works Behind the Scenes

When a task is offloaded to libuv, libuv internally performs several operations.

For instance, if you initiate a file read operation, once the data is received back from the operating system (OS), it is libuv's responsibility to handle the callback function and eventually send it to the call stack for execution.

libuv Workflow:
===============

Your Code: fs.readFile("file.txt", callback)
        │
        ▼
    V8 Engine
    "This is async!"
        │
        ▼
┌─────────────────────┐
│       libuv         │
│  (Thread Pool)      │
└──────────┬──────────┘
           │
    Calls OS to read file
           │
           ▼
┌─────────────────────┐
│  Operating System   │
└──────────┬──────────┘
           │
    File data ready!
           │
           ▼
┌─────────────────────┐
│       libuv         │
│  (Callback Queue)   │
└──────────┬──────────┘
           │
    Event Loop checks...
    Call Stack empty?
           │
           ▼
    Execute Callback!

Multiple Async Tasks Completing Together

Now, imagine there are millions of lines of JavaScript code running within the JavaScript engine. If an asynchronous task like an API call returns data very quickly from the OS to libuv, that API call must wait in the callback queue within libuv until the V8 engine is free to process it.

So, let's say multiple asynchronous tasks, like an API call returning results, a setTimeout, and a file read operation, are completed simultaneously. To manage this, libuv maintains separate callback queues for different types of tasks!

Multiple Callback Queues:
=========================

┌─────────────────────────────────────────┐
│              libuv                      │
│                                         │
│  ┌─────────────┐  ┌─────────────┐      │
│  │   Timers    │  │    Poll     │      │
│  │   Queue     │  │   Queue     │      │
│  │ (setTimeout)│  │ (I/O ops)   │      │
│  └─────────────┘  └─────────────┘      │
│                                         │
│  ┌─────────────┐  ┌─────────────┐      │
│  │   Check     │  │   Close     │      │
│  │   Queue     │  │   Queue     │      │
│  │(setImmediate)│ │(socket close)│     │
│  └─────────────┘  └─────────────┘      │
│                                         │
└─────────────────────────────────────────┘

The Event Loop's Role

This is where the event loop comes into play. The event loop continuously monitors the call stack, checking if it's empty. If the stack is empty, the event loop takes tasks from the callback queues and pushes them onto the call stack for execution.

The event loop's main responsibility is to ensure that all pending tasks in the callback queues are executed at the appropriate time and in the correct order of priority.

Four Major Phases of the Event Loop

Let's take a closer look at what happens internally inside the event loop!

The event loop in LIBUV operates in four major phases:

Event Loop Phases:
==================

     ┌─────────────────────┐
     │                     │
     │   1. TIMERS PHASE   │ ← setTimeout, setInterval
     │                     │
     └──────────┬──────────┘
                │
                ▼
     ┌─────────────────────┐
     │                     │
     │   2. POLL PHASE     │ ← I/O callbacks (fs.readFile)
     │                     │
     └──────────┬──────────┘
                │
                ▼
     ┌─────────────────────┐
     │                     │
     │   3. CHECK PHASE    │ ← setImmediate
     │                     │
     └──────────┬──────────┘
                │
                ▼
     ┌─────────────────────┐
     │                     │
     │ 4. CLOSE CALLBACKS  │ ← socket.close()
     │                     │
     └──────────┬──────────┘
                │
                └────────────→ Back to Timers Phase

1. Timers Phase

In this phase, all callbacks that were set using setTimeout or setInterval are executed. These timers are checked, and if their time has expired, their corresponding callbacks are added to the callback queue for execution.

setTimeout(() => {
    console.log("Timer callback!");
}, 1000);

// After 1000ms, callback goes to Timers Queue
// Event Loop executes it in Timers Phase

2. Poll Phase

After timers, the event loop enters the Poll phase, which is crucial because it handles I/O callbacks.

For instance, when you perform a file read operation using fs.readFile, the callback associated with this I/O operation will be executed in this phase. The Poll phase is responsible for handling all I/O-related tasks, making it one of the most important phases in the event loop.

fs.readFile("file.txt", (err, data) => {
    console.log("File read callback!");
});

// Callback goes to Poll Queue
// Event Loop executes it in Poll Phase

3. Check Phase

Next is the Check phase, where callbacks scheduled by the setImmediate function are executed. This utility API allows you to execute callbacks immediately after the Poll phase, giving you more control over the order of operations.

setImmediate(() => {
    console.log("setImmediate callback!");
});

// Callback goes to Check Queue
// Event Loop executes it in Check Phase

4. Close Callbacks Phase

Finally, in the Close Callbacks phase, any callbacks associated with closing operations, such as socket closures, are handled. This phase is typically used for cleanup tasks, ensuring that resources are properly released.

socket.on('close', () => {
    console.log("Socket closed!");
});

// Callback executed in Close Callbacks Phase

Event Loop Cycle with process.nextTick() and Promises [Very Important!]

Before the event loop moves to each of its main phases (Timers, Poll, Check, and Close Callbacks), it first processes any pending microtasks.

Microtasks include:

  • process.nextTick() callbacks
  • Promise callbacks

This ensures that these tasks are handled promptly before moving on to the next phase.

Priority Order:
===============

1. process.nextTick()  ← HIGHEST PRIORITY
2. Promise callbacks
3. Timers Phase (setTimeout, setInterval)
4. Poll Phase (I/O callbacks)
5. Check Phase (setImmediate)
6. Close Callbacks Phase


┌──────────────────────────────────────────┐
│           MICROTASK QUEUE                │
│  ┌──────────────┐  ┌──────────────┐     │
│  │process.next  │  │   Promise    │     │
│  │   Tick()     │  │  Callbacks   │     │
│  └──────────────┘  └──────────────┘     │
│         ↓                ↓               │
│    Executed BEFORE each phase!           │
└──────────────────────────────────────────┘

Q1: What is the output?

const fs = require("fs");
const a = 100;

setImmediate(() => console.log("setImmediate"));

fs.readFile("./file.txt", "utf8", () => {
    console.log("File Reading CB");
});

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

function printA() {
    console.log("a=", a);
}

printA();

console.log("Last line of the file.");

Answer:

OUTPUT:
a= 100
Last line of the file.
Timer expired
setImmediate
File Reading CB

Explanation:

  1. const a = 100; - Variable declared
  2. setImmediate - Callback A placed in Check Queue
  3. fs.readFile - libuv starts reading file, callback C will go to Poll Queue when ready
  4. setTimeout(0) - Callback B placed in Timers Queue
  5. printA() - Synchronous, prints "a= 100"
  6. console.log - Prints "Last line of the file."
  7. Call stack empty - Event loop starts
  8. Microtasks - None pending
  9. Timers Phase - Executes B, prints "Timer expired"
  10. Poll Phase - Nothing ready yet
  11. Check Phase - Executes A, prints "setImmediate"
  12. File ready - Executes C, prints "File Reading CB"

Q2: What is the output?

const fs = require("fs");
const a = 100;

setImmediate(() => console.log("setImmediate"));

Promise.resolve("promise").then(console.log);

fs.readFile("./file.txt", "utf8", () => {
    console.log("File Reading CB");
});

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

process.nextTick(() => console.log("Process.nextTick"));

function printA() {
    console.log("a=", a);
}

printA();

console.log("Last line of the file.");

Answer:

OUTPUT:
a= 100
Last line of the file.
Process.nextTick
promise
Timer expired
setImmediate
File Reading CB

Explanation:

  1. Synchronous code executes first:
    • setImmediate → Check Queue
    • Promise.then → Microtask Queue
    • fs.readFile → libuv handles
    • setTimeout → Timers Queue
    • process.nextTick → Microtask Queue (higher priority)
    • printA() → "a= 100"
    • console.log → "Last line of the file."
  2. Call stack empty - Microtasks first!
    • process.nextTick → "Process.nextTick"
    • Promise → "promise"
  3. Timers Phase: "Timer expired"
  4. Check Phase: "setImmediate"
  5. Poll Phase (when file ready): "File Reading CB"

Important Concept!

When the event loop is empty and there are no more tasks to execute, it enters the poll phase and essentially waits for incoming events.

Q3: What is the output?

const fs = require("fs");

setImmediate(() => console.log("setImmediate"));

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

Promise.resolve("promise").then(console.log);

fs.readFile("./file.txt", "utf8", () => {
    setTimeout(() => console.log("2nd timer"), 0);
    process.nextTick(() => console.log("2nd nextTick"));
    setImmediate(() => console.log("2nd setImmediate"));
    console.log("File reading CB");
});

process.nextTick(() => console.log("Process.nextTick"));

console.log("Last line of the file.");

Answer:

OUTPUT:
Last line of the file.
Process.nextTick
promise
Timer expired
setImmediate
File reading CB
2nd nextTick
2nd setImmediate
2nd timer

Explanation:

  1. Synchronous: "Last line of the file."
  2. Microtasks:
    • process.nextTick → "Process.nextTick"
    • Promise → "promise"
  3. Timers Phase: "Timer expired"
  4. Check Phase: "setImmediate"
  5. Poll Phase (file ready):
    • "File reading CB" (sync inside callback)
    • Schedules: 2nd timer, 2nd nextTick, 2nd setImmediate
  6. Microtasks after Poll: "2nd nextTick"
  7. Check Phase: "2nd setImmediate"
  8. Timers Phase (next cycle): "2nd timer"

Q4: What is the output? (Nested process.nextTick)

const fs = require("fs");

setImmediate(() => console.log("setImmediate"));

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

Promise.resolve("promise").then(console.log);

fs.readFile("./file.txt", "utf8", () => {
    console.log("File reading CB");
});

process.nextTick(() => {
    process.nextTick(() => console.log("inner nextTick"));
    console.log("Process.nextTick");
});

console.log("Last line of the file.");

Answer:

OUTPUT:
Last line of the file.
Process.nextTick
inner nextTick
promise
Timer expired
setImmediate
File reading CB

Explanation:

process.nextTick callbacks have higher priority than other asynchronous operations. This means that if you have nested process.nextTick callbacks, the inner process.nextTick callback will be executed before moving to promises!

  1. Synchronous: "Last line of the file."
  2. Microtasks - process.nextTick:
    • Outer nextTick executes → "Process.nextTick"
    • Inner nextTick scheduled
    • Inner nextTick executes → "inner nextTick"
  3. Microtasks - Promise: "promise"
  4. Timers Phase: "Timer expired"
  5. Check Phase: "setImmediate"
  6. Poll Phase: "File reading CB"

Quick Recap

Phase Handles
Microtasks process.nextTick(), Promise callbacks (before each phase!)
Timers setTimeout, setInterval
Poll I/O callbacks (fs.readFile, network)
Check setImmediate
Close Close callbacks (socket.close)

Priority Order (Important!)

Priority (Highest to Lowest):
=============================

1. process.nextTick()     ← Always first!
2. Promise.then()         ← Microtask
3. setTimeout/setInterval ← Timers Phase
4. I/O callbacks          ← Poll Phase
5. setImmediate           ← Check Phase
6. Close callbacks        ← Close Phase

Interview Questions

Q: What is the event loop in Node.js?

"The event loop is the mechanism that allows Node.js to perform non-blocking I/O operations despite JavaScript being single-threaded. It continuously monitors the call stack and callback queues, executing callbacks when the stack is empty."

Q: What are the phases of the event loop?

"The event loop has four main phases: Timers (setTimeout/setInterval), Poll (I/O callbacks), Check (setImmediate), and Close Callbacks. Before each phase, microtasks (process.nextTick and Promises) are processed."

Q: What is the difference between process.nextTick() and setImmediate()?

"process.nextTick() callbacks are executed before any I/O operations in the current iteration, making them higher priority. setImmediate() callbacks are executed in the Check phase, after I/O callbacks in the Poll phase."

Q: What is the priority order of async operations?

"The priority order is: process.nextTick() (highest), Promise callbacks, setTimeout/setInterval, I/O callbacks, setImmediate, and close callbacks (lowest)."

Key Points to Remember

  • Event Loop = Heart of Node.js async handling
  • Callback Queue = Where callbacks wait
  • Thread Pool = Handles heavy operations
  • 4 Phases: Timers → Poll → Check → Close
  • Microtasks run BEFORE each phase
  • process.nextTick() = Highest priority
  • Promise.then() = After nextTick, before timers
  • setImmediate = After I/O (Poll phase)
  • Event loop waits in Poll phase when idle

What's Next?

Now you understand how the event loop works in detail! In the next episode, we will:

  • Learn about thread pool in libuv
  • Understand worker threads
  • Build practical applications

Keep coding, keep learning! See you in the next one!