Node.js Single-Threaded Event Loop Explained

This article explains how Node.js manages high-concurrency operations using its single-threaded event loop mechanism. We will explore the architecture behind Node.js, the phases of the event loop, and how it delegates heavy tasks to system threads to achieve non-blocking, asynchronous Input/Output (I/O) execution.

The Myth of the Single Thread

While Node.js is often described as a single-threaded runtime, this only applies to the main execution thread. JavaScript execution, the Call Stack, and the Event Loop all run on this single main thread.

To handle resource-intensive tasks (like database queries, network requests, or file system access) without freezing the main thread, Node.js utilizes libuv, a multi-platform C library. Libuv provides a system thread pool (typically four threads by default) that handles heavy asynchronous tasks in the background.

How the Event Loop Works

The event loop acts as an orchestrator. When an asynchronous operation is initiated, Node.js offloads it to the system kernel or the libuv thread pool. Once the operation completes, the callback associated with that operation is placed into a queue. The event loop continuously monitors the Call Stack and these queues. If the Call Stack is empty, the event loop pushes the pending callbacks onto the stack for execution.

The event loop operates in a continuous cycle, moving through six distinct phases:

  1. Timers: This phase executes callbacks scheduled by setTimeout() and setInterval() once their threshold has elapsed.
  2. Pending Callbacks: Executes I/O callbacks deferred from the previous loop iteration, such as TCP errors.
  3. Idle, Prepare: Used only internally by Node.js for housekeeping.
  4. Poll: Retrieves new I/O events. The event loop will block here and wait for incoming I/O callbacks if no other timers or immediate scripts are scheduled.
  5. Check: Executes callbacks registered via setImmediate().
  6. Close Callbacks: Executes callbacks for closed connections or resources, such as socket.on('close', ...).

Microtask Queues: NextTick and Promises

In addition to the main phases, Node.js features two important microtask queues that do not belong to the event loop itself:

These microtask queues have the highest priority. Whenever an operation in the Call Stack finishes—regardless of the current phase of the event loop—Node.js immediately drains the process.nextTick queue, followed by the Promise queue, before continuing to the next event loop phase.

Why This Architecture Efficiently Scales

Traditional web servers (like Apache) create a new thread for every incoming client connection. If a server receives thousands of concurrent requests, it must manage thousands of threads, leading to high memory consumption and CPU overhead due to context switching.

Node.js avoids this overhead by using a single thread to accept all incoming requests. When a request requires I/O, Node.js registers a callback and immediately moves on to handle the next request. This non-blocking model allows a single instance of Node.js to handle tens of thousands of concurrent connections with minimal system resource usage.