DEV Community

Cover image for Node JS - The Event Loop
Om Vaja
Om Vaja

Posted on

Node JS - The Event Loop

We have discussed why Node JS is single-threaded and also multi-threaded in our article called "Node Internals". It’ll give you a solid foundation on Node’s architecture and set the stage for understanding the magic of the Event Loop!

Node js could be considered single-threaded because of the Event Loop. But, what is the event loop?

I always start with the restaurant analogy because I think it becomes easy to understand technical details.

So, In the restaurant main chef takes orders from the order list and gives them to the team of assistants. When food is ready the chef serves the food. If any VIP customers come then the chef prioritize this order.

If we take this analogy into our consideration then we can say that...

In the context of Node JS Event Loop.

  • Chef is the Event Loop that manages tasks and delegating work.

  • Team of Assistance is a worker thread or the OS that handles the execution of the delegated tasks to them.

  • Order List is a task queue for tasks waiting for their turn.

  • VIP customer is a Microtask that has high priority and is completed before regular tasks.

To, Understand Event Loop we have to first understand the difference between Microtasks and Macrotasks.

Microtask

Microtask means tasks that have some high priority and are executed after the currently executing Javascript code completes, but before moving to the next phase of the Event Loop.

Example:

  • process.nextTick
  • Promises (.then, .catch, .finally)
  • queueMicrotask

Macrotask

These are lower-priority tasks queued for execution at a later phase in the Event Loop.

Example:

  • setTimeout
  • setInterval
  • setImmediate
  • I/O operations

The Event Loop

When we run asynchronous tasks in Node.js, the Event Loop is at the heart of everything.

Thanks to the Event Loop, Node.js can perform non-blocking I/O operations efficiently. It achieves this by delegating time-consuming tasks to the operating system or worker threads. Once the tasks are completed, their callbacks are processed in an organized manner, ensuring smooth execution without blocking the main thread.

This is the magic that allows Node.js to handle multiple tasks concurrently while still being single-threaded.

Phases

There are six phases in an Event Loop and each phase has its own queue, which holds specific types of tasks.

1.Timers phase

In this phase timer related callbacks are handled such as setTimeout, and setInterval.

Node js checks the timer queue for callbacks whose delay has expired.

If a timer delay is met, its callback is added to this queue for execution.

console.log('Start');

setTimeout(() => {
  console.log('Timer 1 executed after 1 second');
}, 1000);

setTimeout(() => {
  console.log('Timer 2 executed after 0.5 seconds');
}, 500);

let count = 0;
const intervalId = setInterval(() => {
  console.log('Interval callback executed');
  count++;

  if (count === 3) {
    clearInterval(intervalId);
    console.log('Interval cleared');
  }
}, 1000);

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

Output:

Start
End
Timer 2 executed after 0.5 seconds
Timer 1 executed after 1 second
Interval callback executed
Interval callback executed
Interval callback executed
Interval cleared
Enter fullscreen mode Exit fullscreen mode

2.I/O callback phase

This phase's purpose is to execute callbacks for completed I/O (Input/Output) operations, such as reading or writing files, querying databases, handling network requests, and other asynchronous I/O tasks.

When you any asynchronous I/O operation in Node.js (like reading a file using fs.readFile) comes then, the operation is delegated to the operating system or worker threads. These I/O tasks are executed outside the main thread in a non-blocking manner. Once the task is completed, a callback function is triggered to process the results.

The I/O Callbacks Phase is where these callbacks are queued for execution once the operation finishes.

const fs = require('fs');

console.log('Start');

fs.readFile('example.txt', 'utf8', (err, data) => {
  if (err) {
    console.log('Error reading file:', err);
    return;
  }
  console.log('File contents:', data);
});

console.log('Middle');

setTimeout(() => {
  console.log('Simulated network request completed');
}, 0);

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

Output

Start
Middle
End
Simulated network request completed
File contents: (contents of the example.txt file)
Enter fullscreen mode Exit fullscreen mode

3.Idle phase

In this phase, no user-defined work is performed instead in this phase event loop gets ready for the next phases. only internal adjustments are done in this phase.

4.Poll phase

The Poll Phase checks whether there are pending I/O events (like network activity or file system events) that need to be processed. It will immediately execute the callbacks associated with these events.

If no I/O events are pending, the Poll Phase can enter a blocking state.

In this blocking state, Node.js will simply wait for new I/O events to arrive. This blocking state is what makes Node.js non-blocking: It waits until new I/O events trigger callback executions, keeping the main thread free for other tasks in the meantime.

Any callbacks for completed I/O operations (such as fs.readFile, HTTP requests, or database queries) are executed during this phase. These I/O operations may have been initiated in previous phases (like the Timers Phase or I/O Callbacks Phase) and are now completed.

If there are timers set with setTimeout or setInterval, Node.js will check if any timers have expired and if their associated callbacks need to be executed. If timers have expired, their callbacks are moved to the callback queue, but they will not be processed until the next phase, which is the Timers Phase.

const fs = require('fs');
const https = require('https');

console.log('Start');

fs.readFile('file1.txt', 'utf8', (err, data) => {
  if (err) {
    console.log('Error reading file1:', err);
    return;
  }
  console.log('File1 content:', data);
});

fs.readFile('file2.txt', 'utf8', (err, data) => {
  if (err) {
    console.log('Error reading file2:', err);
    return;
  }
  console.log('File2 content:', data);
});

https.get('https://jsonplaceholder.typicode.com/todos/1', (response) => {
  let data = '';
  response.on('data', (chunk) => {
    data += chunk;
  });
  response.on('end', () => {
    console.log('HTTP Response:', data);
  });
});

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

Output:

Start
End
File1 content: (contents of file1.txt)
File2 content: (contents of file2.txt)
HTTP Response: (JSON data from the HTTP request)
Enter fullscreen mode Exit fullscreen mode

5.Check phase

After the Poll Phase has completed its tasks. This phase mainly handles the execution of setImmediate callbacks, which are scheduled to run immediately after the I/O events are processed in the Poll Phase.

setImmediate callbacks are often used when you want to perform an action after the current event loop cycle, such as making sure some task is executed after the system is not busy processing I/O events.

The Check Phase has higher priority over the Timers Phase (which handles setTimeout and setInterval). This means that setImmediate callbacks will always be executed before any timers even if their timers have expired.

setImmediate guarantees that its callback will run after the current I/O cycle and before the next timer cycle. This can be important when you want to ensure that I/O-related tasks are completed first before running other tasks.

const fs = require('fs');

console.log('Start');

fs.readFile('somefile.txt', 'utf8', (err, data) => {
  if (err) {
    console.error(err);
    return;
  }
  console.log('File content:', data);
});

setImmediate(() => {
  console.log('Immediate callback executed');
});

setTimeout(() => {
  console.log('Timeout callback executed');
}, 0);

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

Output:

Start
End
Immediate callback executed
Timeout callback executed
File content: (contents of the file)
Enter fullscreen mode Exit fullscreen mode

6.close phase

The Close Callbacks Phase typically executes when an application needs to clean up before exiting or shutting down.

This phase deals with events and tasks that need to be executed once a system resource, like a network socket or file handle, is no longer needed.

Without this phase, an application might leave open file handles, network connections, or other resources, potentially leading to memory leaks, data corruption, or other issues.

const http = require('http');

const server = http.createServer((req, res) => {
  res.write('Hello, world!');
  res.end();
});

server.listen(3000, () => {
  console.log('Server is listening on port 3000');
});

setTimeout(() => {
  console.log('Closing the server...');
  server.close(() => {
    console.log('Server closed!');
  });
}, 5000);

process.on('exit', (code) => {
  console.log('Process is exiting with code', code);
});

console.log('End of the script');

Enter fullscreen mode Exit fullscreen mode

Output:

End of the script
Server is listening on port 3000
Closing the server...
Server closed!
Process is exiting with code 0
Enter fullscreen mode Exit fullscreen mode

There is one more special phase in the Event Loop of Node JS.

Microtask Queue

process.nextTick() and promises to execute their callbacks in a special phase in the Event Loop.

process.nextTick() schedules a callback to be executed immediately after the current operation completes, but before the event loop continues to the next phase.

process.nextTick() is not part of any phase in the event loop. Instead, it has its own internal queue that gets executed right after the currently executing synchronous code and before any phase in the event loop is entered.

It's executed after the current operation but before I/O, setTimeout, or other tasks scheduled in the event loop.

Promises have lower priority than process.nextTick() and are processed after all process.nextTick() callbacks.

console.log('Start');

process.nextTick(() => {
  console.log('NextTick callback');
});

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

setTimeout(() => {
  console.log('setTimeout callback');
}, 0);

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

Output:

Start
End
NextTick callback
Promise callback
setTimeout callback
Enter fullscreen mode Exit fullscreen mode

Now, You have an overall idea of how the Event Loop works.

I am giving you one question which answer you can give in the comments.

const fs = require('fs');

console.log('Start');

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

process.nextTick(() => {
  console.log('NextTick callback');
});

setTimeout(() => {
  console.log('setTimeout callback');
}, 0);

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

fs.readFile(__filename, () => {
  console.log('File read callback');
});

console.log('End');

Enter fullscreen mode Exit fullscreen mode

Thank you.

Waiting for your answer.

Top comments (0)