Sitemap

Event Loop in Node.js

A comprehensive guide to understand event loops and its various phases

5 min readFeb 28, 2025

--

Press enter or click to view image in full size
Photo by Artem Sapegin on Unsplash

Introduction

The event loop is the heart of Node.js, enabling non-blocking, asynchronous execution. Unlike traditional multi-threaded models, Node.js is single-threaded but uses an event-driven, non-blocking architecture to handle I/O-bound operations efficiently.

TL;DR

  1. The event loop is a scheduler that enables JavaScript to run asynchronous tasks in a single-threaded environment. It orchestrates execution by pushing callbacks from microtask and macrotask queues to the call stack when it is free, giving priority to the microtask queue.
  2. Microtasks include process.nextTick, Promises, and Mutation Observers, while macrotasks include timers, I/O operations, and network requests.
  3. The event loop processes async work through 6 libuv phases: timers, pending callbacks, idle, poll, check, and close. Microtasks run before the next phase begins, giving them higher priority. Macrotask callbacks are queued by libuv once the async operation completes, and the event loop executes them when it reaches the corresponding phase by pushing them onto the call stack.

Node.js Architecture Overview

Node.js is built on Google’s V8 engine (for JavaScript execution) and libuv (for handling asynchronous I/O). The event loop works with callbacks, microtasks, and a worker pool (via libuv) to manage tasks asynchronously.

Event Loop

The event loop cycles through six phases, processing different types of tasks in each phase. Here’s a detailed breakdown:

Phase 1: Timers (setTimeout & setInterval Callbacks)

  • This phase executes callbacks scheduled using setTimeout() and setInterval().
  • The execution order depends on when the timer expires.

Timers are not guaranteed to execute precisely after the specified time due to event loop delays.

🔹 Example

setTimeout(() => console.log("Timeout Callback"), 0);
console.log("Synchronous Code");

Output:

Synchronous Code
Timeout Callback

Even though setTimeout is 0ms, it runs after synchronous code because the event loop must first complete the current execution.

Phase 2: I/O Callbacks

  • Executes callbacks from I/O operations like file system reads (fs.readFile()), network requests (http.get()), and database queries.
  • Callbacks from non-blocking I/O operations are processed here.

Certain system-related operations like TCP errors are also handled.

🔹 Example

const fs = require('fs');

fs.readFile('test.txt', 'utf8', (err, data) => {
console.log("File Read Complete");
});

console.log("Reading File...");

Output

Reading File...
File Read Complete

Even though fs.readFile() is called first, the callback runs later because it’s handled in the I/O phase.

Phase 3: Idle, Prepare (Internal Use)

  • This phase is for internal optimization in libuv.
  • It is mostly unused by user-space code.

Phase 4: Poll Phase (Handles I/O & Determines Next Phase)

  • The poll phase is one of the most critical phases.
  • If there are pending I/O callbacks, they will be executed.
  • If there are no pending timers, the event loop will:
  • Wait for new I/O events.

If there’s nothing to process, it moves to the check phase.

🔹 Example:

setImmediate(() => console.log("setImmediate Callback"));
setTimeout(() => console.log("setTimeout Callback"), 0);

Output:

setTimeout Callback
setImmediate Callback

Why? Because the poll phase first executes any ready I/O callbacks before moving to setImmediate.

Phase 5: Check (setImmediate Callbacks)

  • setImmediate() callbacks are executed in this phase.

Unlike setTimeout(), setImmediate() always executes after the poll phase (if there’s no blocking I/O).

🔹 Example:

setImmediate(() => console.log("setImmediate"));
process.nextTick(() => console.log("nextTick"));
console.log("Synchronous Code");

Output:

Synchronous Code
nextTick
setImmediate
  • nextTick executes immediately before any I/O phases.
  • setImmediate() executes after the poll phase.

Phase 6: Close Callbacks

  • Executes cleanup tasks like socket.on(“close”, callback).
  • Handles resources that need finalization.

4. Microtasks: process.nextTick() & Promise Callbacks

Apart from the six event loop phases, there are microtasks, which are higher priority and execute before the next phase begins.

🔹 Order of Execution:

  1. Synchronous Code
  2. Microtasks (process.nextTick, Promise callbacks)
  3. Event Loop Phases (Timers → I/O → Poll → Check → Close Callbacks)

🔹 Example:

console.log("Start");

setTimeout(() => console.log("Timeout"), 0);
setImmediate(() => console.log("Immediate"));

process.nextTick(() => console.log("nextTick"));
Promise.resolve().then(() => console.log("Promise"));

console.log("End");

Output:

Start
End
nextTick
Promise
Timeout
Immediate
  • nextTick and Promise always execute before I/O callbacks.
  • setTimeout (Timer phase) and setImmediate (Check phase) follow.

5. Worker Threads and the Thread Pool (Handling CPU-bound tasks)

Although Node.js is single-threaded, it can offload CPU-intensive tasks to a worker thread pool via libuv.

I/O-bound vs CPU-bound

  • I/O-bound tasks (file system, database) don’t block the event loop.
  • CPU-bound tasks (large calculations) block the event loop.

🔹 Example of Blocking CPU Task

const start = Date.now();
while (Date.now() - start < 5000) {} // Blocking the thread for 5s
console.log("Done");

Impact: Blocks all asynchronous operations!

Solution: Use Worker Threads

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

const worker = new Worker(`
const { parentPort } = require('worker_threads');
let sum = 0;
for (let i = 0; i < 1e9; i++) sum += i;
parentPort.postMessage(sum);
`, { eval: true });

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

🔹 Worker Threads execute tasks in parallel without blocking the event loop.

Note: There are other ways to solve this problem as well like running the loops in an async manner by slicing the loop in chunks and using promises to resolve it, the example is showing how it can be handled via Worker threads.

6. Step-by-Step Task Flow in the Event Loop

Step 1: JavaScript Execution Begins (Main Thread)

  1. Synchronous code runs first.
  2. When an asynchronous function is encountered:
    1. The operation is offloaded (to a background thread in libuv for I/O, Timers, etc.).
    2. The callback is registered to run later.
  3. JavaScript continues executing without waiting.

Step 2: The Event Loop Detects Completed Async Tasks

  1. Once an async operation finishes (e.g., a file is read or a timer expires), its callback moves to the appropriate queue.
  2. The event loop continuously checks if any queue contains tasks ready for execution.

Step 3: Processing Tasks in Event Loop Phases

The event loop follows six phases in a continuous cycle.

  1. Timers Phase ⏳
  2. I/O Callbacks Phase 📡
  3. Idle, Prepare (Internal Use)
  4. Poll Phase 🔄
  5. Check Phase ✅
  6. Close Callbacks Phase 🚪

Step 4: Microtasks (High Priority Tasks)

  1. After each phase, before moving to the next one, Node.js checks the Microtask Queue.
  2. process.nextTick() and Promise callbacks (.then()) are executed before returning to the event loop.

Step 5: The Event Loop Repeats

After all callbacks in a phase are processed:

  1. The event loop moves to the next phase.
  2. It keeps checking if new async tasks have completed.
  3. The cycle continues until no pending tasks exist.

7. Summary of Key Takeaways

  1. Node.js uses a single thread : JavaScript runs on a single thread, but asynchronous operations use libuv and worker threads.
  2. Event loop phases: Timers → I/O → Poll → Check → Close Callbacks
  3. Microtasks: process.nextTick() and Promises execute before the next event loop phase.
  4. Timers vs setImmediate: setTimeout() executes in Timer phase; setImmediate() in Check phase.
  5. Worker threads: Offload CPU-intensive tasks to avoid blocking the event loop.

--

--