TL;DR
In this article, I’ll explain how Node.js can handle multiple requests even though JavaScript is a single-threaded language. But before diving into the technical details, let’s get familiar with some foundational concepts.
A process is an instance of a program in execution. A process can have multiple threads and has its own memory space—isolated from other processes.
A thread is the smallest unit of execution within a process. Threads within the same process share memory. If one thread crashes, it can affect the others.
Process and Threads
I/O stands for Input/Output. It refers to operations that involve external interaction, such as:
I/O is considered the slowest fundamental operation in computing when compared to CPU and memory operations due to physical limitations—it often involves hardware like disk drives or network interfaces.
Operation | Time (Approx.) | Compared to CPU (1 ns) |
---|---|---|
CPU Instruction | 1 ns | 1× (baseline) |
RAM Access | 10–100 ns | 10× to 100× slower |
SSD Read | 0.1–1 ms (100,000–1,000,000 ns) | 100,000× to 1,000,000× slower |
HDD Read | 5–15 ms (5,000,000–15,000,000 ns) | 5,000,000× to 15,000,000× slower |
Network Request | 10–1000 ms | 10,000,000× to 1,000,000,000× slower |
Despite being slow, I/O is the most common operation in most applications. Around 90% of the time, your app is performing CRUD operations or requesting data from external services.
In traditional programming, blocking I/O means that the program pauses and waits for the I/O operation to complete before continuing to the next line. Here's an example in Node.js using readFileSync
:
jsconst fs = require('fs');console.log("Reading file...");// 🔴 Blocking I/Oconst data = fs.readFileSync('example.txt', 'utf-8');console.log("File content:", data);console.log("Finished reading.");
Because I/O is slow, traditional programs often use multiple threads to handle multiple requests. Each thread handles one request. Java used this approach for a long time.
Handle Requests by Threads
Context Switching
Think of the CPU like your brain—it performs best when focusing on one thing at a time. Constantly switching between tasks (threads) is costly.
Frequent context switching can cause threads to spend significant time idle, which results in inefficient CPU resource utilization.
If Node.js is single-threaded and one I/O operation blocks the thread, wouldn’t other requests have to wait? That’s where non-blocking I/O comes into play.
Modern operating systems support non-blocking I/O mechanisms. These allow system calls to return immediately, without waiting for data to be read or written. Once the data is ready, the OS notifies the application.
This mechanism uses an event demultiplexer (aka event notification interface), which watches I/O resources and queues events when they're ready to be processed.
jsconst server = createServer((socket) => {const id = `${++connectionId}`;connections[id] = socket;console.log(`New connection: ${id}`);socket.on('data', (data) => {console.log(`Data from ${id}: ${data.toString()}`);handleClientData(id, data);});socket.on('end', () => {console.log(`Connection ended: ${id}`);delete connections[id];});socket.on('error', (err) => {console.error(`Error on connection ${id}:`, err);});});
Only when the data is ready will the server begin processing it. This way, Node.js can use one main thread to handle many requests, minimizing context switching and improving efficiency for I/O-bound applications.
Handle Multiple Requests with one Thread
You might be wondering: where does the event loop fit into all of this?
The event demultiplexer queues up events, and the event loop continuously checks if there’s work to do—if so, it runs the corresponding callback.
This pairing is how Node.js manages to stay non-blocking and efficient.
jswhile (true) {const completedEvents = demultiplexer.watch(watchedResources);for (const event of completedEvents) {eventQueue.push(event.callback);}if (!isMainThreadBusy && eventQueue.length > 0) {const callback = eventQueue.shift();execute(callback);}checkTimers();processNextTickQueue();}
Here’s an improved version of your paragraph with better grammar, smoother flow, and consistency in tone—while keeping your original meaning intact:
How Event Loop Works
The image above illustrates how the event loop works. It continuously checks the event queue, which is managed by the event demultiplexer. When an event is ready, the event loop retrieves its associated callback from the queue and executes it.
Sounds reasonable, right? But this brings up an important question: JavaScript is single-threaded—so how can it handle asynchronous I/O operations?
Even with an event demultiplexer, wouldn’t performing an I/O operation still block the thread, since it's running in the same execution context? So how is Node.js able to handle async I/O without freezing the main thread?
That’s where libuv comes into play.
libuv is a library created by the Node.js team to abstract away OS-specific behavior and provide a consistent API for asynchronous operations.
It powers:
In short, it allows I/O operations to run on separate threads behind the scenes, while your JavaScript runs on the main thread.
Libuv in Node.js
So from the image above, you mean that when perform the async operation, the async I/O is perform on different threads? And the Node.js application is running on different thread (Main Thread)
Yeah, that is true, exactly. Let take a look of this example:
jsconst fs = require('fs');console.log('Start reading'); // 🟢 (1)fs.readFile('sample.txt', 'utf8', (err, data) => {console.log('File is read'); // 🔵 (3 - inside callback)});console.log('Other work...'); // 🟡 (2)
Output:
txtStart readingOther work...File is read
textYour Code (JS): <-- 🧠 Main Thread (Event Loop)├─ console.log("Start reading") <-- 🟢 (1) MAIN THREAD├─ fs.readFile("sample.txt", cb) <-- registers callback├─ console.log("Other work...") <-- 🟡 (2) MAIN THREAD↓libuv (Queues to thread pool) <-- 🌊 libuv offloads work↓[ Worker Thread reads file ] <-- ⚙️ Thread Pool (I/O happens here)↓libuv callback → Event Loop <-- libuv pushes result back↓cb(data) → console.log("File is read") <-- 🔵 (3) MAIN THREAD runs callback
So, even though your main thread is doing one thing, the I/O is handled in a background thread—thank to Libuv.
Technically, the JavaScript execution happens on a single thread. But Node.js can still perform I/O on multiple threads thanks to libuv’s thread pool.
Node.js handles multiple requests efficiently thanks to:
This architecture makes Node.js ideal for I/O-heavy applications like:
However, it’s not ideal for CPU-intensive tasks (e.g. image processing, complex calculations), as these block the main thread. While you can use Worker Threads, computational workloads in JavaScript can still be challenging to scale efficiently.
Sure! Here's an improved and polished version of that section, including proper formatting, the image, and a clear call-to-action:
If you want to dive deep into how Node.js works under the hood — including topics like event loops, asynchronous patterns, and scalable architecture — this book is a must-read:
Node.js Design Patterns (4th Edition)
Design and implement production-grade Node.js applications using proven patterns and techniques.