Skip to main content
Knowledge Hub

Understanding the Node.js Event Loop

How Node.js handles concurrency through its event-driven architecture

Last updated: June 28, 2025

Node.js is built on an event-driven, non-blocking I/O model. At the heart of this architecture is the event loop, a mechanism that allows Node.js to perform non-blocking I/O operations despite JavaScript being single-threaded.

Understanding the event loop is crucial for building performant Node.js applications. It explains how Node.js handles thousands of concurrent connections, why some operations block the entire process, and how to write code that takes full advantage of Node.js’s asynchronous capabilities.

The Single-Threaded Model

JavaScript runs in a single thread. This means only one piece of code executes at a time. However, Node.js achieves concurrency through the event loop, which coordinates asynchronous operations.

When you make an asynchronous call, Node.js doesn’t wait for it to complete. Instead, it continues executing other code and handles the result when it’s ready. This is what makes Node.js efficient for I/O-heavy applications.

The Event Loop Phases

The event loop operates in several distinct phases, each with its own queue of callbacks:

┌───────────────────────────┐
│   Timers (setTimeout)      │
├───────────────────────────┤
│   Pending Callbacks        │
├───────────────────────────┤
│   Idle, Prepare            │
├───────────────────────────┤
│   Poll (I/O)               │ ← Most callbacks execute here
├───────────────────────────┤
│   Check (setImmediate)     │
├───────────────────────────┤
│   Close Callbacks          │
└───────────────────────────┘

    (Repeat if callbacks exist)
  1. Timer phase: Executes callbacks scheduled by setTimeout and setInterval
  2. Pending callbacks: Executes I/O callbacks deferred to the next loop iteration
  3. Idle, prepare: Internal use only
  4. Poll phase: Retrieves new I/O events and executes I/O related callbacks
  5. Check phase: Executes setImmediate callbacks
  6. Close callbacks: Executes close event callbacks

The event loop continues through these phases until there are no more callbacks to process, at which point it exits.

Example: Event Loop Phase Execution

const fs = require('fs');

console.log('Start');

setTimeout(() => console.log('Timer 1'), 0);
setImmediate(() => console.log('Immediate 1'));

fs.readFile(__filename, () => {
  console.log('I/O callback');
  setTimeout(() => console.log('Timer 2'), 0);
  setImmediate(() => console.log('Immediate 2'));
});

console.log('End');

// Typical output:
// Start
// End
// Timer 1
// I/O callback
// Immediate 2
// Timer 2

Microtasks vs Macrotasks

Understanding the difference between microtasks and macrotasks is essential for predicting execution order.

Macrotasks include setTimeout, setInterval, setImmediate, and I/O operations. These are queued in the event loop phases.

Microtasks include Promise callbacks, process.nextTick, and queueMicrotask. These have higher priority and execute before the next event loop phase.

The execution order is: process.nextTick callbacks, then Promise callbacks, then the next event loop phase.

Example: Execution Order

console.log('1: Start');

setTimeout(() => console.log('2: setTimeout'), 0);

Promise.resolve().then(() => console.log('3: Promise'));

process.nextTick(() => console.log('4: nextTick'));

console.log('5: End');

// Output:
// 1: Start
// 5: End
// 4: nextTick
// 3: Promise
// 2: setTimeout

This demonstrates the priority: synchronous code runs first, then process.nextTick, then Promise callbacks, and finally setTimeout (macrotask).

Non-Blocking I/O

When Node.js performs an I/O operation, it doesn’t block the thread. Instead, it delegates the work to the operating system and continues executing JavaScript. When the I/O completes, a callback is queued to handle the result.

This is why Node.js can handle many concurrent connections with a single thread. While waiting for one I/O operation, it can process other requests.

Example: Non-Blocking I/O

const fs = require('fs').promises;

async function handleMultipleRequests() {
  console.log('Request 1: Starting file read');
  const file1 = fs.readFile('file1.txt'); // Non-blocking
  
  console.log('Request 2: Starting file read');
  const file2 = fs.readFile('file2.txt'); // Non-blocking
  
  console.log('Both requests initiated, waiting...');
  
  // Both operations run concurrently
  const [data1, data2] = await Promise.all([file1, file2]);
  
  console.log('Both files read');
}

handleMultipleRequests();

This demonstrates how Node.js can handle multiple I/O operations concurrently without blocking.

The Thread Pool

While JavaScript execution is single-threaded, Node.js uses a thread pool for certain operations. File system operations, DNS lookups, and some crypto operations use worker threads from libuv’s thread pool.

By default, the thread pool has four threads, but this can be configured. Operations that use the thread pool are still asynchronous from JavaScript’s perspective, but they run in parallel at the OS level.

Common Pitfalls

Blocking the event loop is a common mistake. CPU-intensive operations, synchronous file I/O, or long-running loops prevent the event loop from processing other events. This makes the application unresponsive.

To avoid this, break up CPU-intensive work, use worker threads for heavy computation, and always prefer asynchronous APIs.

Another pitfall is assuming execution order. Because of the event loop phases and microtask priority, callbacks might not execute in the order you expect. Understanding the phases helps predict behavior.

Performance Considerations

The event loop’s efficiency depends on keeping each phase quick. If a callback takes too long, it delays all subsequent callbacks in that phase and the next phases.

Monitor event loop lag to identify bottlenecks. Tools like clinic.js and 0x can help visualize event loop behavior and identify blocking operations.

Best Practices

Keep callbacks fast. If you need to do heavy work, break it into smaller chunks or use worker threads.

Use setImmediate for deferring work to the next iteration of the event loop. Use process.nextTick when you need to ensure a callback runs before any other asynchronous operation.

Avoid blocking operations. Always use asynchronous APIs for I/O. If you must do CPU-intensive work, use worker threads or break it into chunks.

Summary

The event loop is Node.js’s mechanism for handling concurrency in a single-threaded environment. Understanding its phases, the difference between microtasks and macrotasks, and how I/O operations are handled is essential for writing efficient Node.js applications.

By keeping operations non-blocking and callbacks fast, you allow the event loop to process many events efficiently, which is what makes Node.js suitable for building scalable network applications.