Node.js is a popular tool for building fast and scalable server-side applications using JavaScript. But what makes it so powerful? Behind the scenes, Node.js uses a smart architecture that lets it handle many tasks at the same time — without blocking or waiting.
In this blog, we’ll break down how Node.js works, including:
Working of Node.js and architecture
Processes and threads
Synchronous vs. asynchronous behavior
Worker threads
Single threaded and multithreading
What is Node.js?
Node.js is a runtime environment that allows you to run JavaScript on the server. It is built on:
Google’s V8 JavaScript engine: Compiles JavaScript into machine code
Libuv library: Provides the event loop, asynchronous I/O, and thread pool
Node.js core APIs: File system, networking, streams, and more
Node.js uses a single-threaded event-driven model to handle multiple concurrent clients efficiently without spawning new threads for each connection.
Node.js Architecture and Working
Lets understand event loop and how node.js work internally.
- Nodejs uses libuv library to run the javascript code in run time environment and also handle asynchronous operations.
Now, understand what is libuv library.
Libuv
libuv is a multi-platform support library that focuses on asynchronous I/O. It is written in C and is used internally by Node.js to abstract away operating system differences, providing:
Event loop implementation
Thread pool
File system operations
Networking support
Timers
Working
Node.js is a runtime environment that allows you to run JavaScript on the server. It’s built on the Google V8 JavaScript engine, Libuv library, and Node.js core APIs (like the file system, networking, etc.).
Despite being single-threaded at its core, Node.js can handle non-blocking operations efficiently. Let's break down how this works:
Main Thread (Master Thread)
When the Node.js server starts, it creates a main thread (sometimes referred to as the master thread), which is responsible for executing the application code. This main thread represents a single process that runs your server.
In this main thread, we have:
Event loop cycle
Event queue
Thread pool
When a request comes to the server, the event queue waits for the request or event. The event loop in the idle/prepare phase waits for requests to process. Once a request arrives, the event loop checks whether the task is blocking or non-blocking.
Blocking vs. Non-Blocking Operations
Blocking Operations: These are operations that block the main thread, causing other tasks in the thread pool to wait. For example, heavy computations, file system operations, or synchronous database queries. When these operations are running, other tasks cannot execute, which can cause the server to slow down.
Non-Blocking Operations: These operations run in the background and don't block the main thread. This allows the event loop to continue handling other tasks while waiting for the non-blocking task to complete. For example, asynchronous operations like setTimeout, file reading, and database queries.
Event Loop and Event Queue
When a request hits your Node.js server (e.g., a login request), it gets handled through the event loop. The event loop processes tasks in two main categories:
🧵 1. Microtask Queue
Includes:
Promise.then()
,async/await
(under the hood),process.nextTick()
Priority: Higher
Executed right after the current code finishes, but before any macrotasks.
Fast, short tasks that must run immediately
🧵 2. Macrotask Queue
Includes:
setTimeout()
,setInterval()
, I/O operationsPriority: Lower
Executed after microtasks are done
Once all tasks are completed, the event loop sends the response to the user.
Login API Example (with both queues)
const express = require('express');
const app = express();
app.use(express.json());
app.post('/login', async (req, res) => {
console.log('🟢 1. Login request received');
setTimeout(() => {
console.log('🟡 4. setTimeout (Macrotask)');
}, 0);
Promise.resolve().then(() => {
console.log('🔵 3. Inside Promise.then (Microtask)');
});
console.log('🟢 2. End of login handler');
res.send('Login successful');
});
app.listen(3000, () => {
console.log('✅ Server is running on port 3000');
});
Timers, Pending Callbacks, Poll, Check, and Close Callbacks
The Node.js event loop has several phases that manage the execution of asynchronous operations. These phases are:
1. Timers:
Executes callbacks scheduled by setTimeout() and setInterval().
If a timer's time has expired, its callback runs here.
Example :
setTimeout(() => {
console.log("Timer triggered after 2 seconds");
}, 2000);
2. Idle/Prepare(Internal Phase) :
Used internally by Node.js and libuv.
Prepares for the Poll phase by collecting data and organizing things.
As a developer, you typically don’t interact with this phase, but it ensures the loop functions efficiently.
3. Pending Callbacks: This phase runs delayed system-level operations — mostly things you don’t control directly in your code, but Node.js handles behind the scenes.
Think of it like this:
“If Node.js has some low-level work (like networking errors or TCP socket responses) that couldn’t run right away, it puts them here to be handled later.”
These callbacks are usually added by Node.js C++ internals (libuv) — not your JavaScript code.
✅ Real-world Analogy:
Imagine you're running a server and it tries to connect to a remote server (like for email or file transfer). If the connection fails, the error callback is queued in this phase.
Example:
const net = require('net');
const server = net.createServer((socket) => {
console.log('Client connected');
socket.on('error', (err) => {
console.log('Socket error:', err.message);
});
});
server.listen(3000, () => {
console.log('TCP server running on port 3000');
});
If something goes wrong with the TCP connection (e.g., client disconnects abruptly),
The error callback doesn’t run immediately.
It goes into the Pending Callbacks phase, and Node.js handles it when that phase comes around.
4. Poll: Retrieves new I/O events and executes their callbacks (like reading from a file or waiting for DB responses).
If there are no I/O tasks:
If there are setImmediate callbacks waiting, the poll phase ends and moves to check.
Otherwise, it will wait for more I/O events.
Example :
// Simulated DB check
db.query("SELECT * FROM users WHERE email = ?", [email], (err, user) => {
// This callback runs in the poll phase
});
5. Check:
Executes setImmediate() callbacks.
These are guaranteed to run after I/O tasks and before the next loop iteration.
setImmediate(() => {
console.log("SetImmediate callback runs in Check phase");
});
6. Close Callbacks:Runs cleanup code when something closes (like sockets, streams, or file descriptors).
Example :
stream.on("close", () => {
console.log("Stream closed. Cleanup runs in Close Callbacks phase.");
});
Thread Pool (For Blocking Operations)
For blocking operations, like filesystem tasks or synchronous database queries, the event loop sends these tasks to a thread pool. The thread pool, by default, has 4 threads, but we can increase the number of threads as needed (up to 128).
The thread pool works as follows:
1. When a blocking task arrives, it is sent to the thread pool.
2. The thread pool waits for the main thread (call stack) to be free.
3. Once the thread pool gets an available thread, it processes the task.
4. After the task is complete, it sends the result back to the main thread via the event loop.
Worker Threads in Node.js
In cases where a task is CPU-heavy or takes a long time, we use worker threads. Worker threads are not part of the main thread or the thread pool, but they help offload heavy tasks to run in parallel. Each worker thread runs in its own separate instance of the Libuv library and can be used to perform tasks in the background.
Example of Worker Threads:
const { Worker } = require('worker_threads');
const worker = new Worker('./worker.js');
worker.on('message', (message) => {
console.log('Message from worker:', message);
});
worker.postMessage('Start processing');
This allows Node.js to handle multiple tasks without blocking the main thread.
Clustering in Node.js
Since Node.js is single-threaded, it can only utilize one CPU core by default. However, we can scale Node.js applications by creating multiple server processes (workers) using the cluster module. Each worker runs on a different CPU core, making the application multi-core and improving performance under heavy loads.
Here’s how it works:
Main Thread (Master): The main process creates child processes (workers) for each CPU core.
Workers: Each worker is a separate instance of the server and handles requests independently, with its own event loop and memory.
Why Use Clustering in Node.js?
Let's say you have a streaming backend application that handles billions of requests. If you only have one Node.js server (process), the server could become overwhelmed, leading to performance issues or even crashes.
By using the cluster module, you can create multiple Node.js servers that run on separate CPU cores. Each server handles a portion of the load, improving performance and scalability.
Node.js vs. Other Platforms (IIS / Kestrel and Java)
In Node.js, the cluster
module makes it easy to create replicas of the server. However, in .NET (using IIS / Kestrel) or Java (using multiple JVMs), the replication process is not built-in and must be configured manually using load balancers, reverse proxies, or containerization.
Process and thread
Process
A process is an independent unit of execution that has its own memory space.
Each process runs in its own isolated environment and has its own set of resources (memory, CPU time, etc.).
In Node.js, the main execution environment is a single process (the main process), which is responsible for running the event loop, handling incoming requests, and managing resources.
Thread
A thread is the smallest unit of a CPU's execution. It is a sequence of instructions that can be scheduled and executed by the operating system.
Threads share the same memory space within a process, meaning they can communicate with each other more easily, but they must be synchronized to avoid issues like race conditions.
In Node.js, the event loop and JavaScript code run in a single thread (main thread), but Node.js also uses threads internally for I/O operations via the libuv library (which manages asynchronous operations).
Synchronous vs. Asynchronous in Node.js
1. Synchronous
In synchronous code:
Operations run one after another.
Each task blocks the execution of the next one until it completes.
Example:
console.log("Start");
const result = doHeavyCalculation(); // Blocks until complete
console.log("Result:", result);
console.log("End");
🔴 Problem:
If
doHeavyCalculation()
takes 5 seconds, nothing else runs during that time.This is blocking, and in Node.js (single-threaded), it freezes the entire server.
2. Asynchronous
In asynchronous code:
Operations don't block the execution.
Long-running tasks (like I/O, timers) are offloaded, and a callback or Promise is used when the result is ready.
Example:
console.log("Start");
setTimeout(() => {
console.log("Async task done");
}, 2000);
console.log("End");
Output:
Start
End
Async task done
🟢 Why?
setTimeout
is handled by libuv.Its callback is queued to run later, after 2 seconds.
Meanwhile, other code continues executing — non-blocking behavior.
Worker thread
Node.js is single-threaded by default (main thread = Event Loop). But for CPU-intensive tasks, you can use Worker Threads to run JavaScript in parallel threads, without blocking the main thread.
✅ Use Cases for Worker Threads
CPU-heavy computations (encryption, image processing, large data parsing)
Parallel processing (multi-core utilization)
Avoid blocking the event loop
❌ Don't use for I/O tasks (Node’s async model already handles I/O well)
⚙️ How Do Worker Threads Work?
1. Each Worker has its own thread and V8 engine
→ Each worker runs in its own separate background thread, like a separate copy of Node.js.
2. Workers talk using messages
→ You can send and receive messages between the main thread and worker using:
postMessage()
→ to sendon('message')
→ to receive
3. Data is copied when sent
→ When you send data to a worker, it is copied, not shared.
→ But if you want to share memory, use SharedArrayBuffer
.
📦 Enabling Worker Threads
Worker threads were added in Node.js v10.5.0 (stable in v12+).
Import them from the worker_threads
module.
🧪 Basic Example
🔹 main.js
(main thread)
const { Worker } = require('worker_threads');
const worker = new Worker('./worker.js');
worker.on('message', (msg) => {
console.log('From worker:', msg);
});
worker.postMessage('Start calculation');
🔹 worker.js
(worker thread)
const { parentPort } = require('worker_threads');
parentPort.on('message', (msg) => {
// Simulate heavy computation
let sum = 0;
for (let i = 0; i < 1e9; i++) {
sum += i;
}
parentPort.postMessage(`Done. Sum = ${sum}`);
});
🔄 Output:
From worker: Done. Sum = 499999999500000000
Meanwhile, the main thread remains free to handle other requests!
Single threaded and Multithreading
1 .Single-Threaded in Node.js
By default, Node.js runs JavaScript in a single thread — this is called the main thread, where the event loop operates.
🔹 What It Means:
Only one operation executes at a time in the main thread.
No parallel execution of JS code.
Good for I/O-heavy apps (like APIs, web servers).
Example :
console.log("Start");
setTimeout(() => {
console.log("Async");
}, 1000);
console.log("End");
Even though setTimeout
is async, the JS code is executed on a single thread.
2. Multi-threading in Node.js
Node.js can use multiple threads in two ways:
🔸 A. Worker Threads (for JS parallelism)
You create separate threads for CPU-heavy tasks.
Each worker has its own V8 engine and thread.
Communicate via
postMessage()
andon('message')
.
🔸 B. Thread Pool (built into libuv)
Node uses 4 background threads by default (can increase).
-
Used for:
- File system (
fs.readFile
) - DNS lookup
- Compression
- Crypto (e.g.
bcrypt
)
- File system (
You don’t write these threads manually — Node does it internally.
Top comments (0)