Skip to main content

Command Palette

Search for a command to run...

How Node.js Handles Multiple Requests with a Single Thread

Updated
17 min read
How Node.js Handles Multiple Requests with a Single Thread
H
CS undergrad | Tech enthusiast | Focusing on Web Dev • DSA • ML | Building skills for real-world impact

Node.js runs on a single thread. This seems impossible. How can one thread handle thousands of requests? The answer is the event loop and a clever design pattern. Understand this and Node.js's power becomes obvious.

This is about how Node.js handles concurrency, the event loop, background workers, and why a single-threaded language can scale to handle massive workloads.


The Single-Threaded Nature of Node.js

JavaScript code in Node.js runs on a single thread. Your application code executes one line at a time, in order.

One Thread, One Stack, One Job at a Time

Your JavaScript Code
        ↓
Single Thread
        ↓
One statement at a time
        ↓
Next statement runs when previous finishes
console.log("1");
console.log("2");
console.log("3");

// Output:
// 1
// 2
// 3

The thread processes each line sequentially.

Single-Threaded Means What, Exactly?

function task1() {
  console.log("Task 1 starting");
  for (let i = 0; i < 1000000000; i++) {} // Long operation
  console.log("Task 1 done");
}

function task2() {
  console.log("Task 2 starting");
  console.log("Task 2 done");
}

task1();
task2();

// Output:
// Task 1 starting
// Task 1 done
// Task 2 starting
// Task 2 done

Task 2 has to wait for Task 1 to finish. The single thread blocks. It can't do anything else until Task 1 completes.

This is a problem in a web server. If one request takes 10 seconds, all other requests wait 10 seconds.

So How Does Node.js Handle Multiple Requests?

The trick is: Node.js doesn't actually execute multiple JavaScript operations simultaneously. Instead, it cleverly switches between them using the event loop and delegates heavy work to background threads.


Understanding Concurrency vs Parallelism

These terms sound similar but are fundamentally different.

Parallelism: True Simultaneous Execution

Multiple tasks run at the exact same time on different CPU cores.

Core 1: Task A --------→
Core 2: Task B --------→
Core 3: Task C --------→

All three run simultaneously

Two multi-core processors can do parallel tasks. Node.js on a single thread cannot.

Concurrency: Interleaved Execution

One thread rapidly switches between tasks, making it appear like they run simultaneously.

Thread:  Task A → Task B → Task A → Task C → Task B → Task C → ...

Switching so fast it looks like they're all running at once

Node.js does this. It's not true simultaneous execution, but it feels like it to the user.

The Analogy: Chef Handling Orders

Imagine a restaurant with one chef.

Parallel: Multiple chefs cooking different dishes at the same time.

Concurrent: One chef:

  1. Starts cooking an order (put it on the stove)
  2. While it's cooking, takes the next order
  3. While that's cooking, preps a third order
  4. Checks on the first dish (it's done, plate it)
  5. Gets the second dish, plates it
  6. Continues cooking the third dish
  7. Repeats

The chef isn't cooking multiple dishes at once. But orders are being prepared concurrently. The chef switches between tasks during downtime.

Node.js is the chef. Your requests are orders. The event loop manages the switching.


The Event Loop: The Brain of Node.js

The event loop is what makes concurrency possible. It's the mechanism that keeps Node.js running and switching between tasks.

What Is the Event Loop?

The event loop continuously checks for work to do. It runs through different types of tasks in a specific order.

Event Loop Phases (simplified):

1. Timers → Execute setTimeout/setInterval callbacks
2. Pending I/O → Execute I/O operations
3. Check → Execute setImmediate callbacks
4. Close → Execute cleanup callbacks

When all phases are empty, loop repeats

How It Works

console.log("Start");

setTimeout(function() {
  console.log("After 0ms");
}, 0);

console.log("End");

// Output:
// Start
// End
// After 0ms

Here's what happens:

1. console.log("Start") runs immediately
   Output: Start

2. setTimeout() registers the callback but doesn't execute it
   The callback is added to the event loop queue

3. console.log("End") runs immediately
   Output: End

4. The main JavaScript code is done

5. Event loop checks its queues
   It finds the setTimeout callback

6. Event loop executes the callback
   Output: After 0ms

The callback runs later, not immediately. The event loop scheduled it.

Event Loop Keeps Running

const http = require("http");

const server = http.createServer((req, res) => {
  res.writeHead(200);
  res.end("Hello");
});

server.listen(3000);

console.log("Server listening on port 3000");

Output: Server listening on port 3000

Then the program doesn't exit. The server stays running. Why? The event loop is still running, waiting for requests. When a request comes in, it handles it. When done, it goes back to waiting.

The event loop never stops (until you kill the process).

The Event Loop Executes Your Code

While (event loop is running) {
  if (timers have callbacks) {
    execute timer callbacks
  }
  
  if (I/O operations are done) {
    execute I/O callbacks
  }
  
  if (setImmediate has callbacks) {
    execute setImmediate callbacks
  }
  
  Wait for more events
}

This loop runs constantly, even when nothing is happening (it just waits).


Handling Multiple Requests with One Thread

Now let's see how multiple HTTP requests are handled.

Single-Threaded HTTP Server

const http = require("http");

const server = http.createServer((req, res) => {
  console.log("Request received");
  
  // Simulate some work
  for (let i = 0; i < 100000000; i++) {}
  
  res.writeHead(200);
  res.end("Hello");
});

server.listen(3000);

A request comes in. The callback runs. It does some work. It sends a response. Done.

But what if 5 requests come in simultaneously?

Request 1 Timeline

Time: 0ms    → Request 1 arrives
Time: 0ms    → Callback runs
Time: 0-100ms → Doing work
Time: 100ms  → Response sent

Requests 2-5 Arrive While Request 1 Is Processing

Time: 0ms    → Request 1 arrives → Callback queued
Time: 10ms   → Request 2 arrives → Queued
Time: 20ms   → Request 3 arrives → Queued
Time: 30ms   → Request 4 arrives → Queued
Time: 40ms   → Request 5 arrives → Queued

Time: 100ms  → Request 1 processing done → Response sent

Time: 100ms  → Request 2 callback runs
Time: 200ms  → Request 2 done → Response sent

Time: 200ms  → Request 3 callback runs
...and so on

Requests queue up. They're handled one at a time. This seems bad. But here's the trick: the waiting is hidden.

The Real Trick: Non-Blocking I/O

The problem above assumes blocking work (the for loop). In real applications, work is I/O (network, files, database):

const http = require("http");
const fs = require("fs");

const server = http.createServer((req, res) => {
  // Non-blocking I/O
  fs.readFile("data.txt", (err, data) => {
    res.writeHead(200);
    res.end(data);
  });
});

server.listen(3000);

This is what happens:

Time: 0ms    → Request 1 arrives
Time: 0ms    → fs.readFile() called (non-blocking)
             → Callback registered in event loop
             → Function returns immediately

Time: 0ms    → Event loop is free!
             → Can handle Request 2

Time: 0ms    → Request 2 arrives
Time: 0ms    → fs.readFile() called (non-blocking)
             → Callback registered
             → Returns immediately

Time: 0ms    → Event loop is free!
             → Waiting for I/O to complete

Time: 50ms   → File read completes for Request 1
             → Callback for Request 1 executes
             → Response sent

Time: 50ms   → Event loop is free!
             → Waiting for Request 2's file read

Time: 100ms  → File read completes for Request 2
             → Callback for Request 2 executes
             → Response sent

Two requests are handled "concurrently" but with no blocking. While one is waiting for file I/O, the other can start.


Delegating Work to Background Threads

Some work can't be non-blocking (CPU-intensive tasks). For this, Node.js uses the Worker Thread Pool.

CPU-Intensive Work Blocks the Thread

function expensiveCalculation() {
  let total = 0;
  for (let i = 0; i < 10000000000; i++) {
    total += i;
  }
  return total;
}

const result = expensiveCalculation();
console.log(result);

This takes 10 seconds. During those 10 seconds, Node.js can't handle any requests. It's blocked.

Delegating to Worker Threads

Node.js has a Worker Thread Pool (libuv). You can delegate heavy work:

const { Worker } = require("worker_threads");
const http = require("http");

const server = http.createServer((req, res) => {
  // Delegate to a worker thread
  const worker = new Worker("./worker.js");
  
  worker.on("message", (result) => {
    res.writeHead(200);
    res.end("Result: " + result);
  });
});

server.listen(3000);

The worker thread does the heavy calculation in the background. The main thread remains free to handle other requests.

What Gets Delegated?

By default, Node.js delegates some I/O operations to the worker thread pool:

File I/O (fs module)       → Worker threads
DNS lookups                → Worker threads
Some crypto operations     → Worker threads
Database connections       → Often delegated

Your JavaScript code       → Main thread

When you call fs.readFile(), it doesn't block the main thread. It's delegated to a worker thread. The worker reads the file. When done, the callback is queued in the event loop.

libuv: The Magic Behind the Scenes

libuv is a C library that Node.js uses. It manages:

  1. The event loop
  2. The worker thread pool (usually 4 threads by default)
  3. Platform-specific APIs for file I/O, networking, etc.
Node.js Application
        ↓
libuv (Event Loop + Thread Pool)
        ↓
Operating System APIs
        ↓
Hardware (CPU, Disk, Network)

The Complete Picture: How a Request Is Handled

Let's trace a real HTTP request from start to finish.

Request Handling Flow

1. Request arrives at the server
   ↓
2. Event loop detects it
   ↓
3. Callback function (request handler) queued
   ↓
4. Event loop executes the callback
   ↓
5. Callback calls fs.readFile() (or other I/O)
   ↓
6. fs.readFile() is delegated to worker thread
   Callback is registered
   ↓
7. Event loop is free, returns to waiting
   ↓
8. Worker thread reads file from disk
   ↓
9. File reading completes
   ↓
10. Callback is queued in event loop
    ↓
11. Event loop executes the callback
    ↓
12. Response is sent
    ↓
13. Connection closed

Code Representation

const http = require("http");
const fs = require("fs");

http.createServer((req, res) => {
  // Step 4: Callback runs
  console.log("Request received");
  
  // Step 5-6: Delegate file reading
  fs.readFile("data.txt", (err, data) => {
    // Step 11-12: Callback runs when file is read
    res.writeHead(200);
    res.end(data);
  });
  
  // Step 7: Function returns, thread is free
}).listen(3000);

// Step 1-2-3: Event loop waits for requests

Multiple Requests Timeline

Request 1 arrives → Handler queued
Handler runs      → fs.readFile delegated to worker
                  → Handler returns (thread free)

Request 2 arrives → Handler queued
Handler runs      → fs.readFile delegated to worker
                  → Handler returns (thread free)

Request 3 arrives → Handler queued
Handler runs      → fs.readFile delegated to worker
                  → Handler returns (thread free)

(Meanwhile, workers are reading files)

Worker 1: File 1 done → Callback queued
Worker 2: File 2 done → Callback queued
Worker 3: File 3 done → Callback queued

Event loop: Execute callbacks
Callback 1 → Send Response 1
Callback 2 → Send Response 2
Callback 3 → Send Response 3

Three requests are handled concurrently. The main thread never blocks. Workers handle I/O in parallel.


Why Node.js Scales Well

Efficient Resource Usage

Traditional servers create one thread per request:

Request 1 → Thread 1 (dedicated)
Request 2 → Thread 2 (dedicated)
Request 3 → Thread 3 (dedicated)
...
Request 1000 → Thread 1000 (dedicated)

Memory usage: 1000 threads × memory per thread = a lot of memory

Node.js handles all requests with one thread:

Request 1 → Event Loop
Request 2 → Event Loop
Request 3 → Event Loop
...
Request 1000 → Event Loop

Memory usage: 1 thread + small queues = much less memory

Handling C10K Problem

The C10K problem: How to handle 10,000 concurrent connections?

Traditional threaded servers struggle because:

  • Creating 10,000 threads uses massive memory
  • Thread switching has overhead
  • Each thread has a stack (memory hungry)

Node.js handles it elegantly:

  • One thread handles all connections
  • I/O is delegated to worker threads
  • Connections are just data in memory (lightweight)

Event-Driven Architecture

Node.js is event-driven:

Something happens → Event fires → Callback runs
                    (non-blocking)

This is efficient. The thread doesn't wait for anything. It processes events as they arrive.

Scales Horizontally

For even better scaling, run multiple Node.js processes:

Load Balancer
    ↓
├─ Node.js Process 1 (Single thread)
├─ Node.js Process 2 (Single thread)
├─ Node.js Process 3 (Single thread)
└─ Node.js Process 4 (Single thread)

Requests distributed across processes
Each process handles requests with its event loop

No threads to create. No memory overhead. Clean and fast.


Common Misconceptions

Misconception 1: "Node.js Runs Everything on One Thread"

False. JavaScript code runs on one thread. But I/O operations are delegated to worker threads. Crypto, DNS, and file operations use background threads.

Misconception 2: "Node.js Can't Do Parallel Work"

False. The main thread can't run JavaScript in parallel. But libuv's worker thread pool runs I/O operations in parallel.

Misconception 3: "Node.js Can't Handle CPU-Intensive Work"

True and false. Pure JavaScript running on the main thread blocks. But you can use Worker Threads to run CPU-intensive work in parallel:

const { Worker } = require("worker_threads");

const worker = new Worker("./heavy-calculation.js");

worker.on("message", (result) => {
  console.log("Result:", result);
});

// Main thread is free to handle other requests

Misconception 4: "If One Request Takes 10 Seconds, All Requests Wait"

Only if the request does CPU-intensive work on the main thread. If it does I/O (file read, database query), other requests proceed while it waits.


Practical Example: Web Server

Simple Web Server Handling Multiple Requests

const http = require("http");
const fs = require("fs");

const server = http.createServer((req, res) => {
  console.log("Request:", req.url);
  
  // Non-blocking file read
  fs.readFile("data.txt", (err, data) => {
    if (err) {
      res.writeHead(500);
      res.end("Error");
      return;
    }
    
    res.writeHead(200);
    res.end(data);
    console.log("Response sent for:", req.url);
  });
});

server.listen(3000);
console.log("Server listening on port 3000");

What Happens

  1. Request 1 arrives

  2. Handler runs, calls fs.readFile()

  3. fs.readFile() delegates to worker, returns immediately

  4. Event loop is free

  5. Request 2 arrives

  6. Handler runs, calls fs.readFile()

  7. fs.readFile() delegates to worker, returns immediately

  8. Event loop is free

  9. Workers finish reading files

  10. Callbacks are queued

  11. Event loop executes callbacks

  12. Responses are sent

All handled concurrently with one thread.

Load Test

curl http://localhost:3000 &
curl http://localhost:3000 &
curl http://localhost:3000 &
curl http://localhost:3000 &
curl http://localhost:3000 &

Send 5 requests at once. All handled concurrently. All respond quickly.


Event Loop Phases in Detail

The event loop goes through phases:

Phase 1: Timers

Execute callbacks from setTimeout() and setInterval().

setTimeout(() => {
  console.log("1 second passed");
}, 1000);

Phase 2: I/O Callbacks

Execute callbacks from file reads, network operations, etc.

fs.readFile("file.txt", (err, data) => {
  console.log("File read");
});

Phase 3: Check

Execute setImmediate() callbacks.

setImmediate(() => {
  console.log("Immediate");
});

Phase 4: Close

Execute close callbacks from streams and connections.

connection.on("close", () => {
  console.log("Connection closed");
});

The loop repeats these phases constantly.

Order Matters

setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
Promise.resolve().then(() => console.log("promise"));

// Output (usually):
// promise
// timeout
// immediate

Promises are microtasks (higher priority). They execute before the next phase.


Blocking vs Non-Blocking

Understanding this distinction is crucial.

Blocking: Stops the Event Loop

const fs = require("fs");

// Blocking
const data = fs.readFileSync("file.txt");
console.log(data);

// The event loop is stopped until the file is read

Don't use Sync methods in production. They block.

Non-Blocking: Frees the Event Loop

const fs = require("fs");

// Non-blocking
fs.readFile("file.txt", (err, data) => {
  console.log(data);
});

console.log("This runs before the file is read");

The callback happens later. The event loop continues.

Why Non-Blocking Matters

With blocking:

Request 1 arrives
File read (BLOCKS for 100ms)
Request 2 arrives (waits)
Request 3 arrives (waits)
File read done
Response 1 sent
Request 2 handled (BLOCKS for 100ms)
...all requests delayed

With non-blocking:

Request 1 arrives
File read starts (non-blocking)
Request 2 arrives
File read starts (non-blocking)
Request 3 arrives
File read starts (non-blocking)
Files read (in parallel)
All responses sent
Total time: ~100ms instead of 300ms

Practice Assignment

1. Understand the event loop:

console.log("1");

setTimeout(() => {
  console.log("2");
}, 0);

console.log("3");

// What will the output be? Why?

2. Non-blocking file operations:

const fs = require("fs");

// Write code that:
// 1. Reads 3 files without blocking
// 2. Prints them all when done

fs.readFile("file1.txt", (err, data) => {
  // Your code
});

// ... more files

3. Handle multiple HTTP requests:

const http = require("http");
const fs = require("fs");

// Create a server that:
// 1. Reads a file for each request
// 2. Sends it as response
// 3. Handles multiple requests concurrently

// Send 5 requests: curl http://localhost:3000 &

4. Identify blocking code:

// Which of these is blocking?

// A:
const data = fs.readFileSync("file.txt");

// B:
fs.readFile("file.txt", callback);

// C:
const result = expensiveCalculation();

// D:
setTimeout(callback, 0);

5. Event loop phases:

setTimeout(() => console.log("A"), 0);
setImmediate(() => console.log("B"));
process.nextTick(() => console.log("C"));

// What order will they print?

Common Mistakes

Mistake 1: Using Blocking Operations

// WRONG - blocks the event loop
const data = fs.readFileSync("file.txt");
res.end(data);

// RIGHT - non-blocking
fs.readFile("file.txt", (err, data) => {
  res.end(data);
});

Mistake 2: Long-Running Synchronous Code

// WRONG - blocks for 5 seconds
for (let i = 0; i < 10000000000; i++) {}
res.end("Done");

// RIGHT - delegate to worker thread or break into chunks

Mistake 3: Misunderstanding Callback Timing

// WRONG - expecting callback to run immediately
let data;

fs.readFile("file.txt", (err, contents) => {
  data = contents;
});

console.log(data); // undefined - callback hasn't run yet!

Mistake 4: Not Handling Errors in Callbacks

// WRONG - ignoring errors
fs.readFile("file.txt", (err, data) => {
  console.log(data); // Crashes if error and data is undefined
});

// RIGHT - check for errors
fs.readFile("file.txt", (err, data) => {
  if (err) {
    console.error(err);
    return;
  }
  console.log(data);
});

Mistake 5: Assuming One Request Blocks Others

// WRONG - thinking this blocks other requests
app.get("/slow", (req, res) => {
  fs.readFile("large-file.txt", (err, data) => {
    // While waiting here, other requests ARE handled
    res.end(data);
  });
});

// It doesn't block! Other requests proceed while this waits.

Quick Recap

  • Node.js runs JavaScript on a single thread. Your code executes sequentially, one statement at a time.

  • The event loop is what makes concurrency possible. It continuously checks for work and executes callbacks.

  • I/O is non-blocking. When you read a file or query a database, it's delegated to a worker thread. The main thread continues.

  • Concurrency vs Parallelism:

    • Concurrency: One thread switches between tasks
    • Parallelism: Multiple threads run simultaneously
    • Node.js is concurrent (one thread) but uses parallelism for I/O
  • The worker thread pool (libuv) handles:

    • File I/O
    • DNS lookups
    • Some crypto operations
    • System-level I/O
  • Multiple requests are handled concurrently because:

    • The main thread isn't blocked
    • I/O operations happen in the background
    • Callbacks are queued and executed when ready
  • Node.js scales well because:

    • One thread uses less memory than many threads
    • I/O is efficient (parallel in background)
    • No thread creation overhead
    • Can handle thousands of concurrent connections
  • Never block the event loop:

    • Use non-blocking I/O (fs.readFile, not fs.readFileSync)
    • Avoid long CPU-intensive synchronous code
    • Use Worker Threads for heavy computation
  • The event loop has phases: Timers → I/O → Check → Close → Repeat

  • For true parallelism, use Worker Threads or run multiple Node.js processes.

Master the event loop and concurrency model, and you'll write scalable, efficient Node.js applications.

Happy coding! 🚀


If you enjoyed this article, check out my other blogs on this profile. 🔗 Connect with me: LinkedIn | GitHub | X (Twitter)