In this tutorial we will try to understand Nodejs architecture. This tutorial is designed keeping in mind beginners.
When you write a Node.js application, you usually start with a JavaScript file. For example, create a file named app.js and add the following code:
console.log("Hello, Node.js!");
Open your terminal, navigate to the directory containing the file, and run:
node app.js
You will see the following output:
Hello, Node.js!
This looks simple. You write JavaScript, run a command, and get a result.
But several interesting things happen between typing node app.js and seeing the output. Node.js must load your program, execute your JavaScript, manage memory, communicate with the operating system, and handle tasks such as reading files or receiving network requests.
How does Node.js do all of this?
To answer that question, we need to understand a few important building blocks:
- How a computer executes instructions.
- What a JavaScript engine does.
- How V8 executes JavaScript and manages memory.
- What Node.js adds to JavaScript.
- How libuv handles asynchronous operations.
- Why Node.js can serve many requests even though application JavaScript usually runs on one main thread.
- How the Node.js event loop coordinates asynchronous work.
These concepts are closely connected. Understanding them in the right order will make advanced Node.js topics much easier to learn.
This article is the foundation of a series on Node.js internals. We will start with the basics and gradually build a mental model of what happens inside a running Node.js application.
1. Before Node.js: How Does a Computer Run a Program?
Before understanding Node.js, we need to understand the problem it solves.
A computer processor, also called a CPU, executes machine instructions. These instructions are represented in a form that the processor can understand.
At the lowest level, computers represent information using binary digits, or bits. A bit can have a value of 0 or 1.
Machine code is not simply any collection of zeros and ones. It is a set of instructions encoded according to the processor’s architecture. These instructions tell the CPU what operations to perform.
For example, a machine instruction might tell the CPU to add two numbers, compare values, or move data between registers and memory.
Now consider this JavaScript statement:
const result = 10 + 20;
A typical CPU cannot take this JavaScript statement and execute it directly. JavaScript is a higher-level language. It expresses what we want to do without requiring us to describe every low-level machine instruction.
Something must translate or execute this higher-level code in a way the computer can process.
That is where the JavaScript engine comes in.
2. What Is a JavaScript Engine?
A JavaScript engine is a program that executes JavaScript code.
It understands JavaScript syntax and semantics. It is responsible for turning JavaScript source code into executable operations and managing the runtime aspects of JavaScript, including its memory.
Popular JavaScript engines include Google’s V8, JavaScriptCore, and SpiderMonkey.
V8 is the engine used by Node.js.
When V8 executes JavaScript, it does not simply translate every line into machine code once and then forget about it. Modern JavaScript engines use several techniques to execute code efficiently.
These can include parsing, bytecode interpretation, runtime profiling, and just-in-time compilation.
Let’s look at the process at a high level.
Step 1: V8 receives JavaScript source code
Suppose your application contains:
function add(a, b) {
return a + b;
}
console.log(add(10, 20));
V8 receives the source code that Node.js asks it to execute.
Step 2: V8 parses the code
Parsing means reading the source code and checking that it follows JavaScript’s grammar.
For example, this is valid JavaScript:
const age = 25;
This is not valid JavaScript:
const = 25;
The parser identifies the structure of the program. V8 uses that structure to prepare the code for execution.
Step 3: V8 prepares and executes the code
V8 can compile JavaScript into bytecode and execute that bytecode using an interpreter. It can also compile frequently executed code into optimized machine code when doing so is beneficial.
The exact path depends on the code and the engine’s runtime decisions.
For example, if a function runs many times with predictable types, V8 may optimize it based on what it observes. If its assumptions later become invalid, V8 may deoptimize the function and return to a less specialized execution path.
You do not need to understand all these optimization details yet. The key point is that V8 is responsible for executing JavaScript, and it uses compilation and runtime optimization to do that efficiently.
Step 4: V8 manages JavaScript memory
JavaScript programs create values, objects, arrays, functions, and other data.
V8 manages the memory used by JavaScript values and objects. It also uses garbage collection to reclaim memory occupied by objects that are no longer reachable.
We will explore memory management later in this article.
For now, remember this:
V8 executes JavaScript and manages the memory used by JavaScript.
But V8 alone does not provide everything needed to build a server.
It does not, by itself, give JavaScript the complete Node.js API for reading files, creating HTTP servers, or accessing operating-system services.
For those capabilities, we need Node.js.
3. What Is Node.js, and Why Do We Need It?
JavaScript originally became popular as a language for adding behavior to web pages. Browsers provided JavaScript engines along with browser-specific APIs.
For example, browser JavaScript can interact with the page through the DOM, respond to mouse clicks, and make network requests using browser APIs.
But what if you want to use JavaScript outside a browser?
What if you want to build a web server, read a file from disk, or create a command-line tool?
You need an environment that can execute JavaScript and provide the APIs required for those tasks.
Node.js is a JavaScript runtime environment that allows JavaScript to run outside the browser.
Node.js combines the V8 JavaScript engine with its own runtime APIs and supporting components, including libuv.
Think of the responsibilities this way:
V8 executes your JavaScript code. Node.js provides the surrounding environment that lets your JavaScript interact with the operating system and perform tasks that are useful in server-side applications.
For example:
const fs = require("node:fs");
fs.readFile("notes.txt", "utf8", (error, data) => {
if (error) {
console.error(error);
return;
}
console.log(data);
});
Here, require("node:fs") loads Node.js’s built-in file system module.
The fs.readFile() function asks Node.js to read a file. The callback is called when the operation completes or fails.
V8 executes the JavaScript that makes this request and the JavaScript callback that handles its result. Node.js provides the file system API and coordinates the underlying operation.
This raises an important question.
If V8 executes JavaScript, who handles the file operation while JavaScript continues doing other work?
One of the important components involved is libuv.
4. Understanding the Main Components of Node.js
Before diving into asynchronous programming, let’s build a simple mental model of Node.js.
Imagine your application as a team with different responsibilities.
V8 is responsible for JavaScript execution. It parses and executes your code, performs optimizations, and manages JavaScript memory.
The Node.js core provides built-in APIs. These include modules such as fs, http, net, crypto, and path. The core also connects JavaScript-facing APIs to the underlying native implementations.
libuv provides important asynchronous infrastructure. It implements the event loop and provides facilities such as the worker thread pool and cross-platform operating-system integration.
The operating system provides system-level services. It manages processes, files, sockets, and other resources. It also offers mechanisms that applications can use to wait for and receive I/O events.
These components work together, but they are not interchangeable.
V8 does not replace the operating system. Libuv does not execute your JavaScript functions. Node.js core does not mean that every operation must pass through a C++ wrapper or libuv.
The exact path depends on the API being used.
For example, a JavaScript timer is handled differently from a file read. A TCP socket is handled differently from a cryptographic operation.
That distinction becomes especially important when we study the event loop.
5. What Happens When You Run node app.js?
Let’s return to the original command:
node app.js
What happens after you press Enter?
First, your operating system starts a Node.js process. The operating system loads the executable and the resources needed to run it.
Node.js initializes its runtime components, including V8, and prepares the environment in which your application will execute.
Node.js then loads your application file and executes its JavaScript.
Consider this example:
console.log("Application started");
const total = 10 + 20;
console.log(total);
The output is:
Application started
30
The first console.log() runs. The addition is evaluated. The second console.log() runs.
These statements execute synchronously, meaning that each operation completes its JavaScript execution before the next statement proceeds.
Now consider a different program:
const fs = require("node:fs");
console.log("1. Start");
fs.readFile("notes.txt", "utf8", (error, data) => {
if (error) {
console.error(error);
return;
}
console.log("3. File contents:", data);
});
console.log("2. End");
Assuming the file exists and no other error occurs, the output will normally begin like this:
1. Start
2. End
3. File contents: ...
Why does "2. End" appear before the file contents?
Because fs.readFile() starts an asynchronous operation. The function returns control to the surrounding JavaScript without waiting for the entire file read to finish.
JavaScript continues executing the next statement.
When the file operation completes, Node.js schedules the callback to run. The callback later executes on the JavaScript thread.
This introduces one of the most important ideas in Node.js:
Starting an asynchronous operation does not mean JavaScript executes the callback in parallel.
The underlying operation may progress outside the main JavaScript execution path, but the callback itself executes when Node.js gets an opportunity to run it.
To understand this properly, we first need to understand how JavaScript uses memory and executes functions.
6. The JavaScript Call Stack: How Functions Execute
Every time JavaScript calls a function, it needs to keep track of that function’s execution.
It must know which function is currently running, which values belong to the current execution, and where execution should continue when the function returns.
JavaScript engines use a call stack to manage this execution context.
A stack follows the Last-In, First-Out principle, often abbreviated as LIFO.
Imagine stacking books on a table. You place one book on top of another. When removing a book, you normally remove the top one first.
Function calls work in a similar way.
Consider this code:
function first() {
second();
}
function second() {
third();
}
function third() {
console.log("Hello");
}
first();
When first() is called, its execution begins. It calls second(), which calls third().
Conceptually, the call stack grows like this:
third()
second()
first()
The most recently called function is at the top.
When third() finishes, its stack frame is removed. Execution returns to second(). When second() finishes, execution returns to first().
Eventually, first() also finishes, and its stack frame is removed.
This process is called stack unwinding.
What is a stack frame?
A stack frame represents an active function call. It holds the information needed to manage that execution, including relevant local execution state and the location to return to when the function finishes.
However, it is important not to assume that every local variable is always stored directly on the physical call stack. Modern JavaScript engines can optimize how values are represented and stored.
The call stack is primarily an execution-management concept, not a promise that every JavaScript variable occupies a fixed physical location.
Why does the call stack matter in Node.js?
Consider this code:
function blockForAWhile() {
const start = Date.now();
while (Date.now() - start < 5000) {
// Keep executing JavaScript for approximately five seconds.
}
}
console.log("Before");
blockForAWhile();
console.log("After");
The while loop occupies the main JavaScript thread for about five seconds.
During that time, the main thread cannot run other JavaScript callbacks.
Even if a network response arrives or a timer becomes eligible to execute, the corresponding JavaScript callback must wait until the current JavaScript execution yields control.
This is one reason long-running synchronous code is dangerous in a Node.js server.
A single request that performs a large amount of CPU-intensive JavaScript can delay unrelated requests that need JavaScript execution on the same thread.
This brings us to an important distinction.
Node.js can perform asynchronous operations, but that does not automatically make CPU-intensive JavaScript asynchronous.
7. The Memory Heap: Where JavaScript Objects Live
The call stack helps us understand how JavaScript executes functions. Now we need to understand where its data lives.
Consider this example:
const age = 25;
const user = {
name: "Sam",
age: 25
};
The variable age contains a number. The variable user refers to an object.
JavaScript engines manage the memory needed for these values. A simplified way to picture this is to imagine the execution state holding references to objects stored elsewhere in managed memory.
The memory used for objects and other dynamically managed data is commonly described using the term heap.
For example, imagine the following conceptual layout:
JavaScript execution state
|
| user
v
JavaScript heap
+----------------------+
| Object |
| name: "Sam" |
| age: 25 |
+----------------------+
This is a teaching model rather than a literal diagram of V8’s internal memory layout.
The actual representation of variables and values depends on the engine and its optimizations.
Stack versus heap
The call stack and heap solve different problems.
The call stack tracks active function executions. The heap is used for dynamically managed data, including objects.
When a function returns, its active stack frame is removed. But an object created during that function may remain alive if something else still references it.
Consider:
function createUser() {
const user = {
name: "Sam"
};
return user;
}
const result = createUser();
console.log(result.name);
The function createUser() finishes, but the returned object remains accessible through result.
This shows why the lifetime of an object is not necessarily the same as the lifetime of the function that created it.
How does garbage collection work?
JavaScript programs create objects as they run. Some objects are needed for a long time, while others become unnecessary.
If the engine never reclaimed memory, an application could eventually consume all available memory.
V8 therefore uses garbage collection to identify objects that are no longer reachable from relevant roots and reclaim their memory.
For example:
let user = {
name: "Sam"
};
user = null;
If no other reference to the original object remains, that object may eventually become eligible for garbage collection.
Notice the wording: eligible for garbage collection.
Setting user to null does not immediately delete the object from memory. Garbage collection is managed by V8, and its timing is not guaranteed.
Understanding the call stack and heap helps explain JavaScript execution and memory management. But it does not yet explain how Node.js handles multiple tasks without waiting for each one to finish.
For that, we need asynchronous programming.
8. Synchronous and Asynchronous Operations
Let’s start with a simple example.
Imagine you are making breakfast. You put water on the stove and wait beside it until it boils. Only after it boils do you start preparing anything else.
This is similar to synchronous execution when the program must wait for an operation to complete before proceeding.
Now imagine putting the water on the stove and preparing your breakfast while it heats. You return to the stove when the water is ready.
This is a useful analogy for asynchronous programming.
In synchronous execution, the current flow waits for an operation to finish before proceeding.
In asynchronous execution, a program can initiate an operation and continue with other work while that operation progresses. A callback, Promise, or other completion mechanism communicates the result later.
Consider two Node.js file system APIs.
The synchronous version:
const fs = require("node:fs");
console.log("Start");
const data = fs.readFileSync("notes.txt", "utf8");
console.log(data);
console.log("End");
The asynchronous version:
const fs = require("node:fs");
console.log("Start");
fs.readFile("notes.txt", "utf8", (error, data) => {
if (error) {
console.error(error);
return;
}
console.log(data);
});
console.log("End");
In the synchronous version, JavaScript waits for the file read to complete before moving to the next statement.
In the asynchronous version, the file read is initiated and JavaScript continues. The callback runs later.
The difference is not that the asynchronous API magically makes the file read instantaneous. The difference is how the application waits for and handles completion.
This is particularly useful for servers, where waiting synchronously for one file operation could prevent the main JavaScript thread from handling other work.
9. What Is libuv?
Now that we understand asynchronous programming, we can introduce libuv.
libuv is a cross-platform C library that provides the event loop and important asynchronous I/O infrastructure used by Node.js.
It helps Node.js work with different operating systems through a common programming interface.
Its responsibilities include the event loop, operating-system event notification, asynchronous file system support, and a worker thread pool used by selected operations.
Why is this necessary?
Operating systems do not all expose exactly the same mechanisms for handling I/O events. Linux, macOS, and Windows have different APIs and facilities.
Node.js needs a consistent way to coordinate asynchronous work across these platforms.
libuv helps provide that consistency.
It is written in C, while V8 is primarily written in C++. Node.js itself contains native code, including C and C++, alongside JavaScript.
However, it is misleading to imagine Node.js as a simple C++ container with only two components inside it. Node.js is a larger runtime with many interconnected parts.
For our purposes, the important distinction is this:
V8 executes JavaScript. Node.js provides the JavaScript-facing runtime APIs. libuv supplies important event-loop and asynchronous I/O mechanisms.
Does every asynchronous operation use libuv’s worker pool?
No. This is one of the most common misunderstandings about Node.js.
Different kinds of work follow different paths.
For example, asynchronous network operations commonly use operating-system facilities that notify libuv when sockets are ready for reading or writing. These operations generally do not need one libuv worker thread per active network connection.
Many file system operations use libuv’s worker thread pool. Certain DNS operations and cryptographic operations also use that pool.
Some asynchronous operations use other mechanisms, and not every Node.js API relies on libuv in the same way.
To understand the difference, let’s look at networking and file system operations separately.
10. How Node.js Handles Network Operations
Imagine you build an HTTP server:
const http = require("node:http");
const server = http.createServer((request, response) => {
response.end("Hello from Node.js!");
});
server.listen(3000);
The server listens for incoming HTTP requests on port 3000.
Suppose a browser sends a request to this server.
Does Node.js create a new JavaScript thread and wait for each request?
Normally, no.
The operating system manages network communication through its networking stack and socket facilities. Node.js and libuv use operating-system event notification mechanisms to learn when network sockets are ready for work.
Examples include epoll on Linux, kqueue on macOS and other BSD-derived systems, and I/O completion ports, or IOCP, on Windows.
The exact implementation depends on the operating system and the kind of operation involved.
At a high level, the process looks like this:
- The operating system receives network data.
- Its networking facilities make the socket ready for processing.
- The event loop observes the relevant event.
- Node.js processes the request and runs the appropriate JavaScript callback.
- Your application generates a response.
- Node.js and the operating system send the response back to the client.
The critical point is that Node.js does not need a separate JavaScript thread that sits idle for every open network connection.
Instead, it can monitor many connections and run JavaScript when work is ready.
This is one reason Node.js is effective for applications with many concurrent connections and substantial I/O activity.
But remember the limitation: when the server needs to execute JavaScript, that work still has to run on the relevant JavaScript thread.
If one request runs a long synchronous loop, other callbacks waiting for that thread can be delayed.
11. How Node.js Handles File System Operations
Now consider a file read:
const fs = require("node:fs");
fs.readFile("notes.txt", "utf8", (error, data) => {
if (error) {
console.error(error);
return;
}
console.log(data);
});
Reading a file from disk involves operating-system file system operations.
For many asynchronous file system APIs, Node.js uses libuv’s worker thread pool to perform the underlying work without blocking the main JavaScript thread.
A simplified sequence looks like this:
- Your JavaScript calls
fs.readFile(). - Node.js validates and processes the request.
- The native implementation arranges the necessary work.
- The file system operation is performed through the appropriate underlying mechanisms, often involving a libuv worker thread.
- Once the operation completes, Node.js arranges for the completion callback to run on the JavaScript thread.
- The callback processes the result.
There are details hidden by this simplified model. For example, a file read can involve multiple lower-level operations, and the precise implementation can change between Node.js versions.
The important point is that the file system operation can progress without requiring the main JavaScript thread to wait synchronously for it to finish.
When the callback is ready, however, JavaScript must execute it.
The worker thread does not normally execute your fs.readFile() callback as JavaScript.
That separation is essential to understanding Node.js concurrency.
12. Understanding the libuv Worker Thread Pool
The libuv worker thread pool is a set of background threads that can execute certain kinds of work.
Its default size is four threads. The size can be configured using the UV_THREADPOOL_SIZE environment variable, subject to version-specific limits and configuration details.
A simplified diagram looks like this:
Node.js application
|
v
Main JavaScript thread
|
v
V8 execution
|
v
Node.js native APIs
|
v
libuv thread pool
/ | \
v v v
File work Crypto DNS work
This diagram shows common uses of the pool. It is not a complete map of every operation in Node.js.
Selected file system operations, dns.lookup(), and certain cryptographic operations can use the pool.
For example:
const crypto = require("node:crypto");
crypto.pbkdf2(
"password",
"salt",
100000,
64,
"sha512",
(error, derivedKey) => {
if (error) {
console.error(error);
return;
}
console.log("Key generated:", derivedKey.length);
}
);
crypto.pbkdf2() performs a computationally intensive key-derivation operation. Its asynchronous form uses a background thread pool so that the main JavaScript thread does not have to perform the derivation synchronously.
The completion callback still runs on the JavaScript thread.
What happens if all four worker threads are busy?
Imagine that four long-running tasks are occupying the pool.
If another operation needs a worker thread, it may have to wait until one becomes available.
This means that an asynchronous API does not guarantee immediate completion.
A program can be non-blocking from the perspective of its main JavaScript thread while still experiencing delays in its worker pool.
This is why understanding Node.js performance requires more than knowing that the event loop exists.
You must also understand which operations use the worker pool, which use operating-system event notification, and which execute directly on the main JavaScript thread.
13. Is Node.js Single-Threaded?
Now we can answer one of the most frequently asked Node.js interview questions.
Is Node.js single-threaded?
The most accurate beginner-friendly answer is:
Node.js normally executes your application’s JavaScript callbacks on one main JavaScript thread, but the Node.js runtime can use additional threads and operating-system facilities to perform work concurrently.
The distinction between JavaScript execution and background work matters.
Consider this example:
console.log("Start");
setTimeout(() => {
console.log("Timer finished");
}, 1000);
console.log("End");
The main JavaScript thread executes the first statement, schedules the timer, and executes the final statement.
After the timer becomes eligible to run, Node.js runs its callback when the event loop can process it.
The timer does not require the main JavaScript thread to sit in a loop counting down for one second.
Now consider this example:
console.log("Start");
const start = Date.now();
while (Date.now() - start < 3000) {
// Block the main JavaScript thread.
}
console.log("End");
This time, JavaScript itself performs the work for about three seconds.
The main JavaScript thread is occupied, so callbacks that need it cannot run during that interval.
This illustrates the difference between asynchronous I/O and CPU-intensive JavaScript.
Concurrency versus parallelism
These terms are related, but they are not identical.
Concurrency means multiple tasks can make progress over overlapping periods. They do not necessarily execute instructions at the exact same time.
Parallelism means multiple tasks execute at the same time, often on separate CPU cores.
Node.js can handle many concurrent I/O operations without creating one JavaScript thread per connection.
At the same time, background worker threads and operating-system facilities can allow some work to proceed in parallel with main-thread JavaScript.
Node.js also provides the worker_threads module for applications that need to run JavaScript in separate threads. Those workers have their own JavaScript execution contexts and can be useful for CPU-intensive tasks.
We will explore worker threads in a later article. For now, remember that a single main JavaScript thread does not mean the entire Node.js process performs only one kind of work at a time.
14. What Is the Node.js Event Loop?
We now have all the major building blocks:
V8 executes JavaScript. Node.js exposes APIs. The operating system and libuv help handle asynchronous work. Completion events must somehow be coordinated with JavaScript execution.
The event loop is a major part of that coordination.
The Node.js event loop allows the runtime to process eligible callbacks and other scheduled work while continuing to support asynchronous operations.
Imagine that an HTTP server is handling several clients.
One client is waiting for a file to be read. Another client sends more data over a socket. A timer becomes eligible to run. A different operation completes in the background.
Node.js needs a way to determine when to execute the JavaScript callbacks associated with this work.
The event loop participates in that process.
It does not continuously execute every pending callback at random. libuv’s event loop has defined stages and rules for handling different kinds of events.
A simple mental model
Think of the event loop as a coordinator.
When JavaScript starts an asynchronous operation, Node.js arranges for its result to be handled later.
The operation may progress through the operating system or a worker thread.
When completion or another relevant event occurs, the runtime arranges for the associated callback to become eligible for execution.
The event loop and Node.js runtime then help determine when that callback can run.
The callback executes on the JavaScript thread, subject to the scheduling rules and the state of the event loop.
One important qualification: not every asynchronous JavaScript feature is simply another callback sitting in a libuv phase queue. Promises, process.nextTick(), timers, and other APIs have their own scheduling rules.
That is why we need to understand both the event loop phases and microtask processing.
15. The Six Phases of the Node.js Event Loop
The libuv event loop is commonly explained using six phases or stages.
These are not six independent threads. They are stages in the event loop’s processing cycle.
The exact scheduling details can vary by Node.js and libuv version. In particular, Node.js 20 and later use a changed timer-processing arrangement associated with libuv 1.45.0, which affects how timers relate to the poll and check stages.
For a beginner, the following model is useful for understanding the main responsibilities of each stage.
Phase 1: Timers
The timers stage processes callbacks associated with timers such as:
setTimeout(() => {
console.log("Timeout callback");
}, 1000);
setInterval(() => {
console.log("Interval callback");
}, 2000);
A timer specifies a minimum delay before its callback becomes eligible to run. It does not promise that the callback will run at exactly that time.
For example:
setTimeout(() => {
console.log("Timer executed");
}, 0);
This does not mean the callback runs immediately.
The callback must wait until the relevant timer-processing opportunity, and JavaScript already running must finish before the callback can execute.
Other scheduling activity can also affect when the callback runs.
Think of the timer delay as an eligibility threshold, not a precise appointment.
Phase 2: Pending callbacks
This stage handles certain I/O-related callbacks deferred from earlier operations.
The exact kinds of callbacks processed here depend on the underlying implementation.
You generally do not need to manage this phase directly when writing everyday Node.js applications.
It is useful to know that not every operating-system-related callback is handled in the same stage.
Phase 3: Idle and prepare
The idle and prepare stages are internal libuv stages used for runtime housekeeping and preparation.
They are not ordinary application callback stages.
You rarely need to interact with them directly, but they form part of the event loop’s internal organization.
Phase 4: Poll
The poll stage is central to understanding Node.js asynchronous I/O.
It helps process I/O events and their associated callbacks.
For example, when a network socket becomes ready for reading, Node.js can process the corresponding event and deliver data to your application.
The poll stage also plays a role in deciding whether the event loop should wait for more I/O events or proceed to other work.
If there are eligible I/O callbacks, the runtime can process them. If no immediate work is ready, the loop may wait for new events, subject to timer deadlines and other scheduling conditions.
The exact behavior depends on the current state of the loop.
Phase 5: Check
The check stage is where callbacks scheduled using setImmediate() are processed.
For example:
setImmediate(() => {
console.log("Immediate callback");
});
setImmediate() is useful when you want to schedule work for the check stage.
It is particularly important in I/O callbacks because it provides a predictable relationship with other callbacks in that context.
We will examine that relationship in the code example later.
Phase 6: Close callbacks
The close-callback stage handles certain close-related events.
For example:
const net = require("node:net");
const server = net.createServer();
server.on("close", () => {
console.log("Server closed");
});
server.listen(3000, () => {
server.close();
});
When the server closes, Node.js emits the appropriate close event.
Close-related callbacks allow applications to respond to resource shutdown and perform cleanup.
Do the phases always run in exactly the same way?
The six-stage model is useful, but it should not be interpreted as a guarantee that every phase executes application callbacks on every loop iteration.
Some phases may have no work to perform. The loop may wait for I/O. Timers and callback scheduling follow rules that depend on the runtime version and the current state of the loop.
Also, process.nextTick() and Promise microtasks are not simply two additional libuv phases. They follow different scheduling rules.
This distinction is important because many beginner tutorials oversimplify the event loop into a single circular queue.
The real system is more structured than that.
16. Understanding Microtasks: Promises and process.nextTick()
So far, we have discussed timers, I/O callbacks, and the event loop phases.
But what happens when JavaScript schedules a Promise callback?
Consider:
console.log("Start");
Promise.resolve().then(() => {
console.log("Promise callback");
});
console.log("End");
The output is:
Start
End
Promise callback
The Promise callback does not run immediately when .then() is called. It is scheduled as a microtask.
A microtask is a piece of work that the JavaScript runtime processes at a designated checkpoint after the current synchronous execution has completed.
Node.js also has a special scheduling mechanism called process.nextTick().
Consider:
console.log("Start");
process.nextTick(() => {
console.log("nextTick callback");
});
Promise.resolve().then(() => {
console.log("Promise callback");
});
console.log("End");
In a typical CommonJS Node.js script, the output is:
Start
End
nextTick callback
Promise callback
Why?
The synchronous statements run first. Once the current execution reaches the relevant scheduling checkpoint, Node.js processes the process.nextTick() queue before continuing with ordinary Promise microtasks.
For this example, the order is:
- Synchronous JavaScript runs.
- The
process.nextTick()callback runs. - The Promise microtask runs.
There are important qualifications to this rule.
The exact ordering can depend on the execution context. For example, ES modules are evaluated using asynchronous module machinery, so examples involving top-level Promise callbacks and process.nextTick() can produce a different order from equivalent CommonJS examples.
The simplified priority rule is most useful when discussing ordinary CommonJS execution and common callback contexts.
Why does process.nextTick() exist?
It lets Node.js schedule a callback to run after the current operation, before the runtime proceeds with later event-loop work.
Despite its name, process.nextTick() does not mean that the callback will run on the next complete event-loop iteration.
It can run before the event loop advances to another phase.
Can microtasks block the event loop?
Yes.
Consider:
function repeatForever() {
process.nextTick(repeatForever);
}
repeatForever();
Each callback schedules another process.nextTick() callback.
Node.js continues draining this queue, so ordinary event-loop work can be prevented from progressing.
A similar problem can occur with endless Promise microtask chains.
For example:
function repeatForever() {
Promise.resolve().then(repeatForever);
}
repeatForever();
This creates a continuously replenished chain of Promise microtasks.
The lesson is not that microtasks are bad. They are essential to modern JavaScript.
The lesson is that continuously scheduling more high-priority work can prevent the runtime from making progress on timers and I/O callbacks.
This behavior is commonly called event-loop starvation.
17. setTimeout() Versus setImmediate()
One of the most common questions about Node.js scheduling is:
What is the difference between setTimeout() and setImmediate()?
Let’s look at the two APIs.
setTimeout(() => {
console.log("Timeout");
}, 0);
setImmediate(() => {
console.log("Immediate");
});
It is tempting to assume that the timeout must run first because it appears first in the code.
That assumption is incorrect.
When these functions are called from the main module, the order of their callbacks is not guaranteed across all circumstances.
Depending on the timing and runtime conditions, either callback can run first.
Why?
setTimeout() schedules a callback that becomes eligible after its specified delay. setImmediate() schedules a callback for the event loop’s check stage.
Their scheduling mechanisms are different, and the exact timing depends on when the runtime starts processing the event loop.
What happens inside an I/O callback?
Now consider:
const fs = require("node:fs");
fs.readFile(__filename, () => {
setTimeout(() => {
console.log("Timeout");
}, 0);
setImmediate(() => {
console.log("Immediate");
});
});
In this example, both callbacks are scheduled from inside a file system I/O callback.
Under normal Node.js scheduling, the setImmediate() callback runs before the zero-delay timeout callback.
Why?
The file-read callback is being processed in an I/O-related context. The immediate callback is scheduled for the check stage, which follows poll processing in the usual event-loop model. The zero-delay timeout must wait until a suitable timer-processing opportunity.
This makes setImmediate() useful when you want to schedule work to run after the current I/O processing opportunity.
However, this does not mean setImmediate() is universally better than setTimeout(fn, 0).
They have different purposes. Use the API that matches the scheduling behavior you need.
Also, neither function guarantees an exact wall-clock execution time.
18. Let’s Trace a Complete Node.js Example
We have now learned about the call stack, asynchronous operations, libuv, the event loop phases, and microtasks.
Let’s bring these ideas together in one program.
Create a file named app.js:
const fs = require("node:fs");
console.log("1. Synchronous main code starts");
setTimeout(() => {
console.log("2. setTimeout callback executed");
}, 0);
setImmediate(() => {
console.log("3. setImmediate callback executed");
});
Promise.resolve().then(() => {
console.log("4. Promise microtask executed");
});
process.nextTick(() => {
console.log("5. process.nextTick executed");
});
fs.readFile(__filename, () => {
console.log("6. File I/O callback executed");
setImmediate(() => {
console.log("7. Nested setImmediate inside I/O");
});
setTimeout(() => {
console.log("8. Nested setTimeout inside I/O");
}, 0);
});
console.log("9. Synchronous main code ends");
Run it:
node app.js
The most important part of the output is the initial sequence:
1. Synchronous main code starts
9. Synchronous main code ends
5. process.nextTick executed
4. Promise microtask executed
These lines appear in this order in the usual CommonJS execution context.
Let’s understand why.
First, synchronous code executes
The first log statement runs.
Then Node.js schedules a timer, an immediate callback, a Promise microtask, a process.nextTick() callback, and an asynchronous file read.
Scheduling these operations does not mean all their callbacks execute immediately.
JavaScript continues running the remaining synchronous statements, so "9. Synchronous main code ends" is printed before those callbacks.
Next, Node.js processes the scheduled microtasks
After the current synchronous execution reaches the relevant checkpoint, Node.js processes the process.nextTick() callback.
The Promise microtask then runs.
This explains why the initial output places "5. process.nextTick executed" before "4. Promise microtask executed" in this CommonJS example.
What happens to the timer and immediate callback?
The timer and immediate callback have different scheduling rules.
The timer callback becomes eligible after its delay threshold has passed. The immediate callback is associated with the check stage.
Their relative order when scheduled from the main module is not guaranteed.
Therefore, do not treat the following sequence as universal:
2. setTimeout callback executed
3. setImmediate callback executed
The reverse order is also possible.
A correct explanation must distinguish the reliable ordering rules from the ordering that depends on timing.
When does the file-read callback execute?
The file read completes asynchronously. Once its completion callback is ready and Node.js can process it, the JavaScript callback runs.
The callback then schedules two more operations:
setImmediate(() => {
console.log("7. Nested setImmediate inside I/O");
});
setTimeout(() => {
console.log("8. Nested setTimeout inside I/O");
}, 0);
Because these callbacks are scheduled from within the I/O callback, the immediate callback normally runs before the zero-delay timer callback.
The expected relative order for this pair is therefore:
7. Nested setImmediate inside I/O
8. Nested setTimeout inside I/O
The broader lesson is more important than memorizing a single output.
To understand Node.js execution order, identify which work is synchronous, which callbacks belong to which scheduling mechanism, and which ordering guarantees actually exist.
19. A More Accurate Mental Model of the Event Loop
It is easy to imagine the event loop as a single circular queue into which every asynchronous operation places its callback.
That model is convenient, but incomplete.
Different operations use different underlying mechanisms. Network readiness, file system work, timers, Promise microtasks, and process.nextTick() do not all follow one identical queue.
A more useful model separates the work into three broad categories.
First, synchronous JavaScript runs on the main JavaScript thread. While that code is executing, other JavaScript callbacks cannot run on that same thread.
Second, asynchronous operations progress through the mechanisms appropriate to them. Some use operating-system I/O notification. Some use the libuv worker thread pool. Others use runtime-managed scheduling.
Third, callbacks and microtasks execute according to their scheduling rules when the JavaScript thread is available.
For example, a network socket becoming ready does not mean the associated JavaScript callback immediately interrupts a running function. A Promise resolving does not mean its .then() handler interrupts synchronous code. A timer reaching its threshold does not mean its callback runs at that exact moment.
The runtime must wait for the current JavaScript execution to finish and then process scheduled work according to the relevant rules.
This is the foundation for understanding Node.js concurrency.
20. What Does This Mean for Node.js Performance?
Understanding Node.js internals is not just useful for interviews. It helps you write applications that remain responsive under load.
Consider a web server handling hundreds of requests.
Most requests might involve reading data from a database, waiting for a network response, or returning a cached result. These tasks often spend significant time waiting for external systems.
Node.js can handle many such operations concurrently because it does not need to block its main JavaScript thread while each operation waits for I/O.
However, CPU-intensive JavaScript can become a bottleneck.
Consider:
function calculate() {
let total = 0;
for (let i = 0; i < 1_000_000_000; i++) {
total += i;
}
return total;
}
Calling this function on the main JavaScript thread can block the event loop for a long time.
During that period, the application may be unable to process other JavaScript callbacks promptly.
For CPU-intensive work, you may need a different approach, such as using worker threads, a separate process, a task queue, or a dedicated service.
You should also watch out for worker thread pool saturation, long synchronous file operations, expensive JSON processing, and chains of microtasks that keep generating more work.
A good Node.js application is not simply one that uses asynchronous APIs everywhere. It is one that uses the right execution model for each kind of task.
Curiosity: How is asynchronous JavaScript in a browser different from asynchronous JavaScript in Node.js? If browsers do not use libuv, how do async and await work?
JavaScript can run asynchronously in both browsers and Node.js, but the environment that provides asynchronous capabilities is different.
To understand why, remember that JavaScript is a programming language, while a browser and Node.js are runtime environments. The language provides features such as Promises, async, and await. The runtime provides APIs that allow JavaScript to interact with the outside world.
In a browser, the JavaScript engine is part of a larger browser environment. The browser provides APIs such as setTimeout(), fetch(), DOM events, and event listeners. Browser internals, together with the operating system, handle the underlying work and notify the JavaScript runtime when callbacks or Promise reactions need to run.
In Node.js, V8 also executes JavaScript, but Node.js provides a different set of runtime APIs. These include file system operations, HTTP servers, and Node.js-specific scheduling mechanisms. Node.js uses libuv for its event loop and several asynchronous I/O facilities.
For example, consider the following code, which can run in both environments:
async function getData() {
const response = await fetch(
"https://example.com/data"
);
const data = await response.json();
console.log(data);
}
getData();
In a browser, fetch() is provided by the browser’s Web API environment. In modern Node.js, fetch() is also available, but it is provided by the Node.js runtime. The underlying networking implementations are different, even though the JavaScript code looks similar.
When fetch() returns a Promise, await allows the function to suspend its execution until the Promise settles. It does not block the main JavaScript thread while waiting for the network response. Once the Promise settles, the remainder of the function is scheduled to resume through Promise-based microtask processing.
Notice that await itself does not perform the network request. The fetch() API initiates the request, the runtime handles the underlying networking work, and the Promise communicates the result to JavaScript.
The same distinction applies to timers. Browsers provide setTimeout() through their runtime environment, while Node.js provides its own timer implementation. Both allow JavaScript to schedule callbacks, but their underlying implementations and event-loop details differ.
The browser does not need libuv because it already has its own runtime architecture for handling asynchronous work. Browser engines and surrounding browser components coordinate timers, networking, user input, rendering, and other activities. Node.js uses libuv for many comparable runtime responsibilities, but it does so within a server-side runtime.
One important detail is that browsers and Node.js do not have identical scheduling rules. For example, browsers have a rendering process and task sources associated with user interaction, while Node.js has libuv event-loop phases such as poll and check. Node.js also has process.nextTick(), which is not a standard browser JavaScript API.
The key takeaway is that async and await are JavaScript language features, not libuv features. They work in browsers and Node.js because both environments support Promises and provide mechanisms for handling asynchronous operations. The runtime determines how APIs such as networking, timers, and file access are implemented.
21. Common Misconceptions About Node.js Internals
Before finishing, let’s correct several misconceptions that can cause confusion as you continue learning.
Misconception 1: Node.js is just V8
V8 is the JavaScript engine used by Node.js, but Node.js also provides runtime APIs, native integrations, event-loop infrastructure, and other components.
Without those additional components, V8 alone would not provide the full Node.js programming environment.
Misconception 2: Every asynchronous operation runs on a background thread
This is incorrect.
Many network operations use operating-system event notification rather than assigning a worker thread to each connection. Selected file system, DNS, and cryptographic operations use the libuv worker pool.
Different APIs use different mechanisms.
Misconception 3: Asynchronous means parallel
Asynchronous means that the program can initiate work and handle its completion later without synchronously waiting for the entire operation.
Parallelism means that work executes at the same time on separate execution resources.
An asynchronous operation can involve parallel work, but the terms do not mean the same thing.
Misconception 4: setTimeout(fn, 0) executes immediately
It does not.
A zero-delay timer becomes eligible according to timer scheduling rules. It still needs an opportunity to run, and its callback cannot interrupt synchronous JavaScript that is already executing.
Misconception 5: setImmediate() always runs before setTimeout(fn, 0)
This is not true when both are scheduled from the main module. Their order depends on the timing and state of the event loop.
Inside an I/O callback, setImmediate() normally runs before a zero-delay timer scheduled in that same callback.
Misconception 6: Every variable lives on the call stack
This is an oversimplification.
The call stack represents active execution. The heap is used for dynamically managed data such as objects. Modern JavaScript engines can optimize value storage and execution in ways that do not correspond neatly to a beginner’s stack-and-heap diagram.
Misconception 7: The event loop runs JavaScript in the background
The event loop coordinates scheduling. It does not make ordinary JavaScript callbacks run concurrently on the same main thread.
The callback still needs the JavaScript thread to execute.
Understanding this distinction is crucial when diagnosing slow requests and event-loop blocking.
Conclusion
Node.js becomes much easier to understand when we build the architecture from first principles.
A CPU executes machine instructions. A JavaScript engine such as V8 executes JavaScript using parsing, compilation, interpretation, and runtime optimization.
Node.js builds on V8 and provides the APIs needed to develop server-side applications. Its native components connect JavaScript to operating-system facilities and asynchronous infrastructure.
libuv provides the event loop, cross-platform I/O integration, and a worker thread pool used by selected operations.
The operating system and libuv help asynchronous work progress without forcing the main JavaScript thread to wait synchronously for every operation. When callbacks become eligible to run, Node.js schedules their execution according to the relevant event-loop and microtask rules.
The most important idea to remember is this:
Node.js can handle many concurrent operations without running all application JavaScript in parallel.
That is why understanding the call stack, memory heap, asynchronous I/O, worker thread pool, event loop phases, and microtasks gives you a much stronger foundation than simply memorizing the names of the APIs.
Frequently Asked Questions
What is Node.js?
Node.js is an open-source JavaScript runtime environment that allows developers to execute JavaScript outside a web browser. It is commonly used to build web servers, APIs, command-line tools, and network applications.
What is V8 in Node.js?
V8 is the JavaScript engine used by Node.js. It parses and executes JavaScript, compiles code for execution, performs runtime optimizations, and manages JavaScript memory.
What is libuv in Node.js?
libuv is a cross-platform C library that provides the event loop and important asynchronous I/O facilities used by Node.js. It also provides a worker thread pool used by selected operations.
Is Node.js single-threaded or multithreaded?
Application JavaScript normally runs on a main JavaScript thread. However, the Node.js runtime can use background threads, worker threads, and operating-system mechanisms to handle other work concurrently or in parallel.
What is the event loop in Node.js?
The event loop is a mechanism that helps Node.js process scheduled callbacks and asynchronous I/O events. It works alongside other scheduling mechanisms, including Promise microtasks and process.nextTick().
What is the difference between the call stack and the heap in JavaScript?
The call stack tracks active function executions. The heap is used for dynamically managed data, including JavaScript objects. These are useful conceptual models, although modern engines can optimize how values are represented and stored.
Does Node.js use threads for HTTP requests?
Node.js does not normally create one worker thread for every HTTP request. It uses operating-system networking facilities and the event loop to handle many network connections, while application callbacks execute on the JavaScript thread.
What is the default libuv thread pool size?
The default libuv worker thread pool size is four. It can be configured with the UV_THREADPOOL_SIZE environment variable, subject to the supported limits of the Node.js and libuv versions in use.
What is the difference between process.nextTick() and a Promise microtask?
In common CommonJS execution contexts, Node.js processes the process.nextTick() queue before ordinary Promise microtasks at the relevant scheduling checkpoint. The exact behavior can depend on the execution context, particularly when ES modules are involved.
What is the difference between setTimeout() and setImmediate()?
setTimeout() schedules a callback that becomes eligible after a specified delay. setImmediate() schedules a callback for the event loop’s check stage. Their ordering is not guaranteed when both are scheduled from the main module, but setImmediate() normally runs first when both are scheduled within the same I/O callback.