Node.js Event Loop Explained

Node.js is single-threaded, meaning it executes JavaScript code in sequence on a single main thread. Yet, it’s famous for handling thousands of concurrent operations without slowing down. The secret behind this magic?

The Event Loop, the core mechanism that lets Node.js handle asynchronous operations (like network requests, file system reads, or timers) without blocking the main thread.

Why should I understand event loop?

The Event Loop is at the heart of Node.js’s asynchronous programming model.

Understanding how it works and how callbacks, promises, and async/await fit into it gives you the power to write faster, more predictable, and bug-free asynchronous code. Without this understanding, you risk introducing hard-to-debug race conditions, blocking the thread, or misusing async patterns.

Analogy: A One-Server Restaurant

Imagine a small restaurant with one server who handles multiple responsibilities:

  • Taking orders from customers

  • Informing the kitchen about orders

  • Delivering food when it's ready

Here's how it works:

  1. Customer 1 arrives: Server gives them a menu and takes their pizza order.

  2. Server informs kitchen: Kitchen acknowledges and starts preparation.

  3. Customer 2 arrives: Instead of waiting for the pizza, server immediately takes their biryani order.

  4. Kitchen works in parallel: Both orders are being prepared simultaneously.

  5. Pizza ready: Server delivers it to Customer 1.

  6. Biryani ready: Server delivers it to Customer 2.

  7. This cycle repeats for every customer.

The server never waits idle. They efficiently manage multiple customers while food is prepared in the background. That’s exactly how Node.js stays responsive.

In Node.js terms:

  • Server = Event Loop (single-threaded)

  • Kitchen staff = libuv thread pool + OS kernel

  • Orders = Asynchronous tasks

  • Order notes = Event queues

  • The continuous process = The Event Loop cycle

The Event Loop

The event loop allows Node.js to be single-threaded while still handling many concurrent operations. Instead of waiting for a task (like a database query or file read) to finish, Node.js offloads it to the operating system and moves on to the next task. When the offloaded task is complete, a callback function is put into the event queue. The event loop constantly monitors this queue and, when the call stack is empty, it moves the callback from the queue to the stack to be executed.

Here's the step-by-step process:

  1. Node.js starts: Executes your initial script synchronously

  2. Encounters async operations: When it finds setTimeout, fs.readFile, etc., Node.js:

    • Delegates the operation to libuv or the OS

    • Registers the callback function

    • Continues executing the next synchronous code

  3. Operations complete: When async operations finish, their callbacks are placed in appropriate queues

  4. Event loop activates: Once the call stack is empty, the event loop:

    • Processes all microtasks first

    • Then processes macrotasks phase by phase

    • Repeats this cycle continuously

Key Rule: The event loop only processes queued callbacks when the call stack is completely empty.

Event Loop Phases

   ┌───────────────────────────┐
┌─>│           timers          │  <- setTimeout, setInterval callbacks
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │  <- TCP/UDP connection callbacks
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │  <- Internal Node.js operations
│  └─────────────┬─────────────┘      ┌───────────────┐
│  ┌─────────────┴─────────────┐      │   incoming:   │
│  │           poll            │<─────┤  connections, │  <- Most I/O operations
│  └─────────────┬─────────────┘      │   data, etc.  │
│  ┌─────────────┴─────────────┐      └───────────────┘
│  │           check           │  <- setImmediate callbacks
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
└──┤      close callbacks      │  <- Socket.on('close'), cleanup
   └───────────────────────────┘

1. Timers Phase

  • Executes callbacks scheduled by setTimeout() and setInterval()

  • Only runs callbacks whose timer has expired

2. Pending Callbacks Phase

  • Handles callbacks for completed TCP/UDP socket connections

3. Idle, Prepare Phase

  • Internal Node.js operations

  • Not directly relevant to application code

4. Poll Phase (Most Important)

  • Fetches new I/O events (like file reads, network requests, and database queries) and executes their callbacks

  • This is where most application code runs

  • If poll queue is empty:

    • Checks for setImmediate() callbacks → moves to check phase

    • Otherwise, waits for incoming callbacks

5. Check Phase

  • Executes setImmediate() callbacks

  • Runs immediately after poll phase if poll becomes idle

6. Close Callbacks Phase

  • Handles cleanup callbacks (e.g., socket.on('close'))

  • Ensures proper resource cleanup

Important: After each phase, all microtasks are processed before moving to the next phase.

Microtasks

The Node.js event loop has two main queues for asynchronous tasks:

  • Microtask Queue: This queue holds higher-priority tasks. process.nextTick() , queueMicrotask() and promises (.then(), .catch(), .finally()) are the primary examples of microtasks. The event loop empties the entire microtask queue after each phase of the event loop and before moving to the next one.

  • Macrotask Queue: This queue holds lower-priority tasks. Examples include setTimeout, setImmediate, and I/O callbacks. The event loop processes one macrotask per cycle.

Because of this priority system, if you have a process.nextTick and a setTimeout scheduled at the same time, the nextTick callback will always run first. This behavior makes nextTick useful for tasks that need to be "immediately" asynchronous.

Event loop executes tasks in process.nextTick queue first, and then executes promises microtask queue, and then executes macrotask queue.

Execution Order Example

const baz = () => console.log('baz');
const foo = () => console.log('foo');
const zoo = () => console.log('zoo');

const start = () => {
  console.log('start');                    // 1. Synchronous

  setImmediate(baz);                       // 5. Macrotask (check phase)

  new Promise((resolve, reject) => {
    resolve('bar');                        // 2. Promise resolves synchronously
  }).then(resolve => {
    console.log(resolve);                  // 3. Microtask (promise)
    process.nextTick(zoo);                 // 4. Microtask (nextTick)
  });

  process.nextTick(foo);                   // 2. Microtask (nextTick)
};

start();

// OUTPUT:
// start    ← Synchronous execution
// foo      ← process.nextTick (highest priority microtask)
// bar      ← Promise then callback
// zoo      ← process.nextTick (added during promise callback)
// baz      ← setImmediate (macrotask, runs after all microtasks)

Execution Timeline:

  1. console.log('start') runs immediately (synchronous)

  2. setImmediate(baz) is scheduled for check phase

  3. Promise resolves and .then() callback is queued as microtask

  4. process.nextTick(foo) is queued as microtask

  5. Call stack becomes empty → Event loop starts

  6. All microtasks run: foo, then promise callback (bar), then zoo

  7. Move to check phase and run baz

Note: The principle aforementioned holds true in CommonJS cases, but keep in mind in ES Modules, e.g. mjs files, the execution order will be different. The ES Module being loaded is wrapped as an asynchronous operation, and thus the entire script is actually already in the promises microtask queue.

What is libuv?

libuv is a cross-platform C library that provides the core functionality for Node.js's asynchronous, non-blocking I/O operations. In simple terms, it's the "engine" that powers the Node.js event loop. It was primarily developed for use by Node.js.

libuv allows Node.js to perform I/O operations (like reading files, making network requests, and interacting with the file system) without blocking the main thread. It does this by offloading these tasks to the operating system or a thread pool, and then a callback is triggered once the operation is complete.

https://docs.libuv.org/en/v1.x/

Summary

The Node.js event loop is a sophisticated system that enables single-threaded JavaScript to handle thousands of concurrent operations efficiently. Key takeaways:

  1. Single-threaded: JavaScript runs on one thread, but I/O operations are handled asynchronously

  2. Event-driven: Callbacks are queued and processed when the call stack is empty

  3. Phase-based: The event loop processes different types of callbacks in specific phases

  4. Priority system: Microtasks always run before macrotasks

  5. Non-blocking: Properly written async code never blocks the event loop

  6. libuv-powered: The C library handles the heavy lifting of async operations

Understanding the event loop empowers you to write faster, more predictable Node.js applications. The key is to embrace asynchronous patterns and avoid blocking operations that could slow down your entire application.

Remember: The event loop constantly orchestrates the flow of tasks between the Call Stack, Event Queues, and Microtask Queue, powered by libuv's ability to handle I/O asynchronously. This ensures that a single-threaded process can efficiently manage multiple operations without getting blocked.

0
Subscribe to my newsletter

Read articles from Dineshraj Anandan directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Dineshraj Anandan
Dineshraj Anandan