Node.Js event loop and concurrency

June 4, 2025 (1w ago)

Is Node.js Single-Threaded? Unraveling the Event Loop and Concurrency

When thinking about developing backend applications with JavaScript, Node.js emerges as a popular and powerful choice. And in 99% of conversations about its architecture, one phrase is almost a mantra: "Node.js is single-threaded." But what does this really mean, and how does it still manage to handle so many connections?

Why is Node.js "Single-Threaded"?

To understand multithreading (or its traditional absence) in Node.js, we first need to talk about its heart: the Event Loop and its event-driven, non-blocking I/O (Input/Output) architecture.

  • Main Single Thread: Indeed, your JavaScript code in a Node.js application runs, by default, on a single main thread. This means that only one instruction of your code is being executed at any given moment.
  • Non-Blocking I/O: Imagine a waiter in a busy restaurant. If they were "blocking," they would take an order from one table, go to the kitchen, wait for the dish to be ready, deliver it, and only then attend to the next table. Inefficient, right? Node.js acts like a "non-blocking" waiter: it takes the order (an I/O operation, like reading a file or making an API request), passes it to the kitchen (the operating system or other internal threads from libuv, which is a C++ library that manages asynchronous I/O), and immediately goes to attend to other tables.
  • The Event Loop: When the kitchen signals that a dish (I/O operation) is ready, the waiter (Event Loop) picks up the dish and delivers it to the corresponding table (executes the associated callback function).

NodeJs Kitchen

This architecture is fantastic for I/O-bound applications, i.e., applications that spend most of their time waiting for network operations, file reads/writes, or database queries. Node.js can handle tens of thousands of concurrent connections with low memory consumption because it doesn't need to create a new thread for each connection (as in some traditional models).

Synchronous code remains synchronous (and blocking)

If a synchronous function takes a long time (e.g., a complex loop, heavy calculations), it blocks the Call Stack. No other JavaScript instruction can be executed until this function finishes. This leads to an unresponsive Node.js server.

console.log('Start (synchronous)');

function longSynchronousTask() {
  let sum = 0;
  for (let i = 0; i < 1e9; i++) {
    sum += i;
  }
  console.log('Long synchronous task completed');
}

longSynchronousTask();
console.log('End (synchronous)');

/**
This will be the output:
-------------------
Start (synchronous)
Long synchronous task completed
End (synchronous)
-------------------
*/

Asynchronous code can run in the background and makes everything seem magical!

And this way, for example when using I/O calls, things can happen in the background.

console.log('--- Script Start (Synchronous) ---');

const API_ENDPOINT_1 = 'https://example.com?request=1';
const API_ENDPOINT_2 = 'https://example.com?request=2';
const API_ENDPOINT_3 = 'https://example.com?request=3';

function makeAPICall(url, taskName) {
  console.log(`🚀 Starting ${taskName} for ${url}...`);
  return fetch(url)
    .then((response) => {
      // The original article used response.text().length.
      // Note that response.text() itself returns a Promise.
      // For simplicity in demonstrating asynchronicity, we'll log a generic success message here.
      // A more accurate way to get content length might be response.headers.get('content-length').
      console.log(`${taskName} COMPLETED. Response received.`);
    })
    .catch((error) => {
      console.error(`${taskName} FAILED: ${error}`);
    });
}

// Asynchronous Tasks: API Call
makeAPICall(API_ENDPOINT_1, 'API Call 1');
makeAPICall(API_ENDPOINT_2, 'API Call 2');
makeAPICall(API_ENDPOINT_3, 'API Call 3');

console.log('--- All API calls have been INITIATED (Synchronous) ---');
console.log('--- The main script continues executing without blocking... ---');
for (let i = 1; i <= 3; i++) {
  console.log(`Quick synchronous loop at the end: ${i}`);
}
console.log('--- End of Main Script (Synchronous) ---');

/**
This is an example of the output (order of completed calls may vary):

--- Script Start (Synchronous) ---
🚀 Starting API Call 1 for https://example.com?request=1...
🚀 Starting API Call 2 for https://example.com?request=2...
🚀 Starting API Call 3 for https://example.com?request=3...
--- All API calls have been INITIATED (Synchronous) ---
--- The main script continues executing without blocking... ---
Quick synchronous loop at the end: 1
Quick synchronous loop at the end: 2
Quick synchronous loop at the end: 3
--- End of Main Script (Synchronous) ---
✅ API Call 2 COMPLETED. Response received.
✅ API Call 3 COMPLETED. Response received.
✅ API Call 1 COMPLETED. Response received.
*/

Why does this seem parallel?

  1. I/O Call: The fetch() function initiates an HTTP request, but it does not wait for the server's response to continue. It returns a Promise immediately.
  2. Delegation to the Environment: The task of performing network communication (sending the request, waiting for the response, downloading data) is delegated to the environment's network engine (in this case, Node.js handles this via its underlying C++ libraries like libuv, often using a thread pool). This happens in the background, outside the main JavaScript thread.
  3. Immediate Continuation: The main JavaScript script continues its execution. That's why the logs "All API calls have been INITIATED," the synchronous loop, and "End of Main Script" appear before any API response.
  4. Promises and Microtask Queue:
    • When the network response arrives (or an error occurs), the Promise returned by fetch is resolved or rejected.
    • The callbacks provided to the .then() (for success) or .catch() (for error) methods are placed in the Microtask Queue.
  5. Event Loop: As soon as the JavaScript Call Stack becomes empty (after all synchronous code has executed), the Event Loop starts processing callbacks from the Microtask Queue. It takes each resolved (or rejected) Promise callback and executes it.
  6. Network Concurrency: The three network requests to example.com are "in transit" at the same time. Your computer can send and receive data for multiple network connections concurrently. The order in which responses arrive can vary depending on many factors (network latency, example.com server load, etc.), which reinforces the asynchronous nature and the appearance of parallelism.

The Phases of the Event Loop

The Node.js event loop is the central mechanism that manages asynchronous code execution and event handling, enabling its asynchronous and non-blocking nature. Essentially a repeating loop, it operates in multiple distinct phases. Each phase has a FIFO (First-In, First-Out) queue of callbacks (operations to be performed), which is processed entirely (or until a callback limit is reached) before the loop moves to the next phase.

  1. timers: Executes callbacks scheduled by setTimeout() and setInterval().
  2. pending callbacks (also known as I/O callbacks): Executes I/O callbacks (like network errors, or other system operations) that were deferred to the next loop iteration. For example, if a TCP error occurs during an operation, some operating systems wait to report it, and this callback is queued here.
  3. idle, prepare: Used internally by Node.js for preparation tasks. There are no user callbacks directly associated with these phases.
  4. poll: Main phase for processing I/O events. Retrieves new I/O events (e.g., file read complete, data received on a network connection) and executes their callbacks. If the poll queue is empty, Node.js may:
    • Check if there are timers whose time has expired and go to the timers phase.
    • Check if there are callbacks in check (scheduled by setImmediate()) and go to the check phase.
    • If there's nothing else, it will wait (block) for new I/O events.
  5. check: Executes callbacks scheduled with setImmediate(). These run immediately after the poll phase has completed its callbacks and before timers of the next cycle.
  6. close callbacks: Executes callbacks for close events, such as socket.on('close', ...) or process.on('exit', ...).
Shimeji