DEV Community

Cover image for Deep Dive into Node.js Architecture and Internal Workings
Ritam Saha
Ritam Saha

Posted on

Deep Dive into Node.js Architecture and Internal Workings

Introduction

Hello, fellow coders!

In my previous post, we explored the three core pillars of Node.js: the V8 engine (our JavaScript interpreter), Libuv (the magic behind non-blocking I/O and asynchronous operations), and the C++ bindings that elegantly bridge the two.

Today, we go deeper. We'll uncover exactly what happens under the hood when you type node filename.js and press Enter. By the end, the roles of the Event Loop and Thread Pool will feel crystal clear—and you'll walk away with a solid mental model of Node.js's single-threaded yet highly concurrent architecture.

Let's dive in.


NodeJS Architecture at a Glance

Till now we have known that the V8 engine itself being JS interpreter is not enough to build a production server as many things are not part of V8 and are built by the Broswer devs explicitly like console, fetch, Timers, DOM & the other asynchronous tasks that are outsourced.

So we can say, JavaScript Enviroment in Browser in much more different than the NodeJS enviroment as the for the async tasks are done by the LibUV library and the bindings.

NodeJS = V8 + C++ Bindings + LibUV 
Enter fullscreen mode Exit fullscreen mode

In this LibUV there are two main core components that are Event Loop and Thread Pool.

  • The Event Loop — orchestrates non-blocking I/O operations (networking, file system callbacks once data is ready, etc.).
  • The Thread Pool — handles CPU-intensive async tasks that can't run efficiently on the main thread (file I/O for large files, cryptography, DNS lookups, compression, etc.).

By default, the thread pool has 4 threads (configurable up to 128). This design is what allows Node.js to remain "single-threaded" for JavaScript execution while still performing impressively in I/O-heavy workloads.

Now we are going to discuss about the internal working of NodeJS that how a code file runs and the responsibilty of Event Loop and Thread Pool are going to be more clear to you.


Node.js Internal Workings: From Command to Execution

When you run node filename.js, Node.js spawns a process with a single thread—often called the main thread. This is why JavaScript in Node.js is described as single-threaded: your synchronous code and most JavaScript logic run here.
The startup sequence follows these key steps:

  • Project Initialization — Node sets up the runtime, loads built-in modules, etc.
  • Top-level code execution — Any code outside functions (including import/require statements) runs immediately.
  • Event callbacks registration — Async callbacks (from timers, I/O, etc.) get regstered in the memory.
  • Event Loop starts — And keeps running until there's nothing left to do.

The Event Loop is essentially a while(true) loop with a base exit condition. It processes tasks in four main user-visible phases (in FIFO order):

  • Expired Callbacks (timers phase)
  • I/O Polling (poll phase — retrieves completed I/O events)
  • setImmediate callbacks (check phase)
  • Close callbacks (close callbacks phase)

Internal Workings

Let's walk through each phase with practical examples.

Expired Callbacks

A timer specifies the threshold after which a provided callback may be executed rather than the exact time a person wants it to be executed. This phase executes callbacks from setTimeout() and setInterval() whose delay has passed.

import fs from 'fs';
setTimeout(() => console.log('Hello from Timer'), 0);
console.log('Hello from Top Level Code');
Enter fullscreen mode Exit fullscreen mode
Output:

Hello from Top Level Code
Hello from Timer
Enter fullscreen mode Exit fullscreen mode

Here, the import statement and the console.log statement at line 3 are top-level code. Though the setTimeout() is also top level code but the callback isn't as it's managed by the event loop. When the top level codes gets executed the timer gets started and as the threshold here is 0ms so the timer gonna expire immediately, thus after the event loop starts, the expired callback will be executed.

I/O Polling (Poll Phase)

This is where completed asynchronous I/O callbacks (e.g., fs.readFile, network responses) are executed. For operations like reading large files, encryption-decryption, compression, cryptography, Node offloads the actual work to the thread pool. The main thread only gets notified when the work is done—keeping it free for other tasks.

import fs from 'fs';

setTimeout(() => console.log('Hello from Timer'), 0);

fs.readFile('sample.txt', 'utf-8', (err, data) => {
  console.log('File Reading Complete...');
});

console.log('Hello from Top Level Code');
Enter fullscreen mode Exit fullscreen mode

Here, the extra line of code that's been introduced is the readFile(). fs.readFile() is also the top level code but not it's callback, it means that the file is being strted to read but if the file size is much bigger then it's gonna take some time and main thread would be engaged for that only that can't be afford.

So, for potentially blocking file operations (especially with large files), Node offloads the work to the thread pool so the main thread isn't blocked. The callback then fires in the poll phase once complete. So the poll phase will be in the IDLE state and wait for the next iteration to check whether the reading file is completed or not. The main thread will be assigned for the setImmediate phase.

Note: Which task will be executed by worker-thread and which one by the Main thread, it can't be decided by us as it's predefined by NodeJS team. Node.js itself decides which one will be performed by the worker thread and which one will be performed by the main thread, depending on the nature of that task, whether it is synchronous or asynchronous.

setImmediate()

This phase allows the event loop to execute callbacks immediately after the I/O Polling **phase has completed. If the **poll phase becomes idle and scripts have been queued with **setImmediate()** .

**setImmediate()** is actually a special timer that runs in a separate phase of the event loop. It uses the libuv API to schedule callbacks to execute after the poll phase completes.

Generally, as the code is executed, the event loop will eventually hit the poll phase where it may wait for the intensive CPU operation, request, etc. However, if a callback has been scheduled with **setImmediate()** and the poll phase becomes idle and handled by a worker-thread from Thread Pool, then continue to the setImmediate **phase rather than waiting for **poll events.

import fs from "fs";

setTimeout(() => console.log("Hello from Timer"), 0);
setImmediate(() => console.log("Hello from Immediate"), 0);

fs.readFile("sample.txt", "utf-8", function (err, data) {
  console.log(`File Reading Complete...`);
});

console.log("Hello from Top Level Code");
Enter fullscreen mode Exit fullscreen mode
Output:

Hello from Top Level Code
Hello from Timer
Hello from Immediate
File Reading Complete...
Enter fullscreen mode Exit fullscreen mode

Ideally, in this code, File Reading Complete... output should be before the Hello from Immediate output, but due to the size of the file, it is assigned to a worker thread from thread pool, so it may take time for which the poll phase gets into the idle. The setImmediate function's callback executes.

Close Callbacks

This phase handles 'close' events for resources like sockets that were forcibly closed (e.g., socket.destroy()). Most 'close' events are emitted via process.nextTick() instead.

Quick note on process.nextTick()

process.nextTick() is nothing but firing immediately staying on the same phase. Any time you call **process.nextTick()** in a given phase, all callbacks passed to **process.nextTick()** will be resolved before the event loop continues.


setTimeout() vs setImmediate() — The Classic Debate

**setImmediate()** and **setTimeout()** are similar, but may behave in different ways depending on when they are called.

  • **setImmediate()** is designed to execute once the current I/O polling phase completes.
  • **setTimeout()** schedules a script to be run after a minimum threshold in ms has elapsed.

The order in which the timers are executed will vary depending on the context in which they are called. If both are called from within the main module, then timing will be bound by the performance of the process (which can be impacted by other applications running on the machine).

For example, if we run the following script, which is not within an I/O cycle i.e. the main module, the order in which the two timers are executed is non-deterministic, as it is bound by the performance of the process:

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

Output can be any one of the followings:

Hello from Immediate
Hello from Timer
Enter fullscreen mode Exit fullscreen mode

OR

Hello from Timer
Hello from Immediate
Enter fullscreen mode Exit fullscreen mode

And now let's see another script in which both the setImmediate() and setTimeout() functions are within the I/O cycle.

import fs from 'fs';

setTimeout(() => console.log('Hello from Timer'), 0);
setImmediate(() => console.log('Hello from Immediate'));

fs.readFile('sample.txt', 'utf-8', function (err, data) {
  console.log(`File Reading Complete...`);

  setTimeout(() => console.log('Time 2'), 0);
  setTimeout(() => console.log('Time 3'), 0);
  setImmediate(() => console.log('Immediate 2'));
});

console.log('Hello from Top Level Code');
Enter fullscreen mode Exit fullscreen mode

So, inside an I/O callback setImmediate() reliably fires before setTimeout(..., 0) because it runs in the check phase right after poll.

As far as this code is concerned, the import fs statement and the console.log statement at the end should be executed first, as they are the top-level code. Besides that, the setTimeout function, the setImmediate function, and the fs.readFile function should also be executed, as the setTimeout starts the timer and the fs.readFile function starts to read the file. The function will be executed, but not their callbacks.

Next, when the event loop starts, it will check for the expired callback, as a setTimeout function got immediately expired, as that threshold was 0 ms, so "Hello from timer" will be executed next. Ideally, the readFile should be completed, but depending on the file size, it is assigned to a worker thread from the Thread Pool, so it gets the idle state. Then the setImmediate function's callback is executed, as its time is also 0 ms, so "Hello from immediate" will be the output.

Then it will check whether the readFile is completed or not. In some iteration, it will be completed, and when it will be completed, the console.log statement, which is "file reading complete", will be executed next. According to the event loop structure, we can see that after the readFile, I mean the IO polling phase, is completed, then the setImmediate phase should be executed. According to that, immediate 2 will be the output, and then the setTimeout's output will be executed, as the phase order is very much important.


Conclusion

Node.js achieves its legendary performance through a clever division of labor: a single-threaded main thread for JavaScript execution, a powerful Event Loop for orchestrating non-blocking I/O, and a Thread Pool for offloading blocking work.
Understanding the phases—timers, poll, check, and close—plus the subtle differences between setTimeout, setImmediate, and process.nextTick—unlocks the ability to write efficient, predictable code.

Whether you're building APIs, real-time apps, or microservices, this mental model helps you debug weird timing issues, avoid blocking the loop, and squeeze every ounce of throughput from Node.js.
For the canonical reference, check out the official Node.js Event Loop guide:
https://nodejs.org/en/learn/asynchronous-work/event-loop-timers-and-nexttick

Happy coding—and may your event loops never starve! 🚀

Top comments (0)