DEV Community

Robins163
Robins163

Posted on • Originally published at unknowntoplay.hashnode.dev

The Node.js Event Loop — Finally Explained Right

TL;DR: The event loop is how Node.js handles thousands of concurrent operations with a single thread. It's not magic — it's a loop with 6 phases, each processing a specific type of callback. This post breaks down every phase with diagrams, shows you exactly what happens when you run async code, and explains the tricky timing bugs that bite even senior developers.


The Problem

Ask 10 Node.js developers how the event loop works, and you'll get 12 different answers. Most explanations are either:

  1. Too simplified: "It's just a loop that checks for callbacks" (unhelpful)
  2. Too academic: 50 pages of libuv source code (nobody reads this)
  3. Flat-out wrong: "setTimeout(fn, 0) runs immediately" (it doesn't)

I've seen bugs in production — at Mstock, with 100K+ concurrent users — that were caused by developers misunderstanding the event loop. A misplaced process.nextTick() caused a recursive callback that starved I/O and froze the server during peak trading hours.

Let me explain it the way I wish someone had explained it to me.


The Big Picture: What Is the Event Loop?

Node.js is single-threaded for your JavaScript code. But it handles I/O (file reads, network requests, database queries) by offloading those operations to the OS kernel or a thread pool, and then processing the results via callbacks.

The event loop is the mechanism that coordinates this. It's literally a loop — an infinite while(true) that keeps running as long as there's work to do.

  THE EVENT LOOP — SIMPLIFIED VIEW
  ═════════════════════════════════

  ┌──────────────────────────────────────────────┐
  │                                              │
  │    Your JavaScript Code (Single Thread)      │
  │                                              │
  │    console.log('start');                      │
  │    fs.readFile('data.json', callback);        │
  │    setTimeout(fn, 100);                       │
  │    http.get(url, callback);                   │
  │    console.log('end');                        │
  │                                              │
  └──────────────────┬───────────────────────────┘
                     │
                     │  "Hey OS/thread pool,
                     │   handle these I/O tasks"
                     │
                     ▼
  ┌──────────────────────────────────────────────┐
  │                                              │
  │    OS Kernel / libuv Thread Pool              │
  │    (Does the actual I/O work)                │
  │                                              │
  │    ┌─────────┐ ┌──────────┐ ┌──────────┐    │
  │    │ Thread 1│ │ Thread 2 │ │ Thread 3 │    │
  │    │ fs.read │ │ dns.look │ │ crypto   │    │
  │    └─────────┘ └──────────┘ └──────────┘    │
  │                                              │
  └──────────────────┬───────────────────────────┘
                     │
                     │  "Done! Here are the results"
                     │
                     ▼
  ┌──────────────────────────────────────────────┐
  │                                              │
  │    EVENT LOOP                                │
  │    (Picks up completed I/O callbacks         │
  │     and runs them on the main thread)        │
  │                                              │
  │    while (hasWork()) {                       │
  │      processTimers();                        │
  │      processIOCallbacks();                   │
  │      processImmediates();                    │
  │      // ... more phases                      │
  │    }                                         │
  │                                              │
  └──────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

The 6 Phases of the Event Loop

This is where most explanations go wrong. The event loop isn't one queue — it's 6 phases, each with its own queue. The loop visits each phase in order, processes all callbacks in that phase's queue, and then moves to the next phase.

  THE 6 PHASES (executed in order, every iteration)
  ═════════════════════════════════════════════════

   ┌───────────────────────────────────────────┐
   │                                           │
   │    ┌─────────────────────────────────┐    │
   │    │  1. TIMERS                      │    │
   │    │  setTimeout(), setInterval()    │    │
   │    └──────────────┬──────────────────┘    │
   │                   │                       │
   │    ┌──────────────▼──────────────────┐    │
   │    │  2. PENDING CALLBACKS           │    │
   │    │  System-level callbacks          │    │
   │    │  (TCP errors, etc.)             │    │
   │    └──────────────┬──────────────────┘    │
   │                   │                       │
   │    ┌──────────────▼──────────────────┐    │
   │    │  3. IDLE / PREPARE              │    │
   │    │  Internal use only              │    │
   │    └──────────────┬──────────────────┘    │
   │                   │                       │
   │    ┌──────────────▼──────────────────┐    │
   │    │  4. POLL                        │    │
   │    │  I/O callbacks: fs, net, etc.   │◀──── Most time spent here
   │    │  (This is where Node "waits")   │    │
   │    └──────────────┬──────────────────┘    │
   │                   │                       │
   │    ┌──────────────▼──────────────────┐    │
   │    │  5. CHECK                       │    │
   │    │  setImmediate() callbacks       │    │
   │    └──────────────┬──────────────────┘    │
   │                   │                       │
   │    ┌──────────────▼──────────────────┐    │
   │    │  6. CLOSE CALLBACKS             │    │
   │    │  socket.on('close'), etc.       │    │
   │    └──────────────┬──────────────────┘    │
   │                   │                       │
   │                   └───────────────────────┘
   │                   Loop back to Phase 1
   └───────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode
flowchart TD
    A[Timers<br/>setTimeout, setInterval] --> B[Pending Callbacks<br/>System-level callbacks]
    B --> C[Idle / Prepare<br/>Internal use only]
    C --> D[Poll<br/>I/O callbacks - fs, net, http<br/>Node waits here if nothing to do]
    D --> E[Check<br/>setImmediate callbacks]
    E --> F[Close Callbacks<br/>socket.on close]
    F --> A

    style D fill:#3b82f6,color:#fff
    style A fill:#f59e0b,color:#fff
    style E fill:#22c55e,color:#fff
Enter fullscreen mode Exit fullscreen mode

Phase 1: Timers

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

Important: The timer specifies a minimum delay, not an exact delay. If the event loop is busy processing I/O callbacks, your timer callback runs later than expected.

// timer-demo.js — Timers don't run at exact times
const start = Date.now();

setTimeout(() => {
  console.log(`Timer fired after ${Date.now() - start}ms`);
  // Expected: ~100ms
  // Actual:   100-115ms (depending on event loop load)
}, 100);

// Simulate work that delays the timer
const blockFor50ms = Date.now() + 50;
while (Date.now() < blockFor50ms) {} // Blocking the loop

// Output: "Timer fired after ~150ms" (100ms delay + 50ms blocking)
Enter fullscreen mode Exit fullscreen mode

Phase 2: Pending Callbacks

Executes I/O callbacks that were deferred from the previous loop iteration. This handles system-level errors like TCP ECONNREFUSED.

You rarely interact with this phase directly.

Phase 3: Idle / Prepare

Internal to Node.js and libuv. Not relevant to application code.

Phase 4: Poll (The Most Important Phase)

This is where Node.js spends most of its time. The poll phase does two things:

  1. Calculates how long to block and wait for I/O events
  2. Processes I/O callbacks in the poll queue (file reads, network responses, etc.)
  POLL PHASE BEHAVIOR
  ════════════════════

  Enter Poll Phase
        │
        ▼
  ┌─────────────────────┐
  │ Poll queue empty?    │
  └──────────┬──────────┘
             │
     ┌───────┼───────┐
     │ NO    │       │ YES
     ▼       │       ▼
  ┌──────────┐  ┌──────────────────────┐
  │ Execute  │  │ Any setImmediate()   │
  │ ALL I/O  │  │ callbacks scheduled? │
  │ callbacks│  └──────────┬───────────┘
  │ in queue │       ┌─────┼─────┐
  └──────────┘       │ YES │     │ NO
                     ▼     │     ▼
              ┌──────────┐ │ ┌──────────────┐
              │ Move to  │ │ │ WAIT here    │
              │ Check    │ │ │ for new I/O  │
              │ phase    │ │ │ events       │
              └──────────┘ │ │ (up to timer │
                           │ │  threshold)  │
                           │ └──────────────┘
Enter fullscreen mode Exit fullscreen mode

Key insight: When the poll queue is empty and no setImmediate() is scheduled, Node.js blocks here, waiting for new I/O events. This is how Node.js achieves near-zero CPU usage when idle.

Phase 5: Check

Executes setImmediate() callbacks. This phase runs immediately after the poll phase completes.

// setImmediate runs after I/O, setTimeout runs at start of next loop
const fs = require('fs');

fs.readFile(__filename, () => {
  // Inside an I/O callback...

  setImmediate(() => {
    console.log('1. setImmediate');  // Runs FIRST (check phase is next)
  });

  setTimeout(() => {
    console.log('2. setTimeout');   // Runs SECOND (timers phase, next iteration)
  }, 0);
});

// Output (guaranteed order):
// 1. setImmediate
// 2. setTimeout
Enter fullscreen mode Exit fullscreen mode

Phase 6: Close Callbacks

Handles close events like socket.on('close'). Cleanup phase.


Microtasks: The Queue That Jumps the Line

Here's what trips up most developers: between every phase (and even between callbacks within a phase), Node.js processes the microtask queues. These are:

  1. process.nextTick() queue (highest priority)
  2. Promise resolution queue
  MICROTASKS — THEY RUN BETWEEN EVERY PHASE
  ══════════════════════════════════════════

  ┌────────┐   ┌─────────┐   ┌────────┐   ┌─────────┐   ┌────────┐
  │ Timers │──▶│ Micro-  │──▶│Pending │──▶│ Micro-  │──▶│  Poll  │─ ─ ▶
  │        │   │ tasks   │   │Callback│   │ tasks   │   │        │
  └────────┘   └─────────┘   └────────┘   └─────────┘   └────────┘
                    │                          │
                    ▼                          ▼
              ┌──────────┐              ┌──────────┐
              │nextTick()│              │nextTick()│
              │Promise   │              │Promise   │
              └──────────┘              └──────────┘

  Microtask queues are drained COMPLETELY between phases.
  If you keep adding to them, they STARVE the event loop.
Enter fullscreen mode Exit fullscreen mode
flowchart LR
    T[Timers] --> M1[Microtasks<br/>nextTick + Promises]
    M1 --> P[Pending]
    P --> M2[Microtasks]
    M2 --> I[Idle]
    I --> M3[Microtasks]
    M3 --> PO[Poll]
    PO --> M4[Microtasks]
    M4 --> C[Check]
    C --> M5[Microtasks]
    M5 --> CL[Close]
    CL --> M6[Microtasks]
    M6 --> T

    style M1 fill:#ef4444,color:#fff
    style M2 fill:#ef4444,color:#fff
    style M3 fill:#ef4444,color:#fff
    style M4 fill:#ef4444,color:#fff
    style M5 fill:#ef4444,color:#fff
    style M6 fill:#ef4444,color:#fff
Enter fullscreen mode Exit fullscreen mode

process.nextTick() vs Promise vs setTimeout vs setImmediate

This is the single most confusing part of Node.js. Here's the definitive ordering:

// ordering-demo.js — Run this to see the exact execution order

console.log('1. Script start');

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

setImmediate(() => console.log('3. setImmediate'));

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

process.nextTick(() => console.log('5. nextTick'));

console.log('6. Script end');

// OUTPUT:
// 1. Script start         ← Synchronous (runs immediately)
// 6. Script end           ← Synchronous (runs immediately)
// 5. nextTick             ← Microtask (highest priority, before promises)
// 4. Promise              ← Microtask (after nextTick, before macrotasks)
// 2. setTimeout           ← Macrotask (timers phase)*
// 3. setImmediate         ← Macrotask (check phase)*
// 
// * setTimeout vs setImmediate order is NON-DETERMINISTIC
//   when called from the main module (outside I/O callbacks)
Enter fullscreen mode Exit fullscreen mode
  EXECUTION PRIORITY (highest → lowest)
  ══════════════════════════════════════

  ┌─────────────────────────────────────┐
  │  1. Synchronous code                │  ← Runs first, always
  ├─────────────────────────────────────┤
  │  2. process.nextTick()              │  ← Microtask, highest async priority
  ├─────────────────────────────────────┤
  │  3. Promise.then() / await          │  ← Microtask, after nextTick
  ├─────────────────────────────────────┤
  │  4. setTimeout(fn, 0)               │  ← Macrotask, timers phase
  ├─────────────────────────────────────┤
  │  5. setImmediate(fn)                │  ← Macrotask, check phase
  ├─────────────────────────────────────┤
  │  6. I/O callbacks                   │  ← Poll phase
  └─────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

The Dangerous Recursive nextTick

This is the bug we hit at Mstock. process.nextTick() callbacks are processed before any I/O — if you call nextTick recursively, you starve the event loop:

// DANGEROUS — this starves all I/O
function badRecursion() {
  process.nextTick(() => {
    doSomeWork();
    badRecursion(); // nextTick queue never empties!
  });
}
badRecursion();
// Result: setTimeout, setImmediate, I/O callbacks NEVER execute
// The event loop is stuck processing nextTick forever

// SAFE — use setImmediate for recursive operations
function safeRecursion() {
  setImmediate(() => {
    doSomeWork();
    safeRecursion(); // Gives I/O a chance to run between iterations
  });
}
Enter fullscreen mode Exit fullscreen mode
  RECURSIVE nextTick vs setImmediate
  ═══════════════════════════════════

  nextTick (DANGEROUS):
  ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐
  │ tick │→│ tick │→│ tick │→│ tick │→│ tick │→ ... forever
  └──────┘ └──────┘ └──────┘ └──────┘ └──────┘
  I/O:   ❌ NEVER RUNS ❌

  setImmediate (SAFE):
  ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐
  │ imm  │→│ I/O  │→│ imm  │→│ I/O  │→│ imm  │→ ...
  └──────┘ └──────┘ └──────┘ └──────┘ └──────┘
  I/O:   ✅ RUNS BETWEEN EACH ✅
Enter fullscreen mode Exit fullscreen mode

Real-World Example: Event Loop in a Stock Trading Server

Here's how the event loop handles a typical moment during market hours at Mstock:

  ONE TICK OF THE EVENT LOOP (during market hours)
  ═══════════════════════════════════════════════════

  Phase 1: TIMERS
  ├── Price refresh timer (every 1s) → fetch latest prices from exchange
  ├── Session cleanup timer (every 30s) → remove expired sessions
  └── Health check timer (every 5s) → ping database and Redis

  Phase 2: PENDING CALLBACKS
  └── TCP error from disconnected client → log and clean up

  Phase 4: POLL (I/O)
  ├── WebSocket message: user subscribes to RELIANCE → add to channel
  ├── WebSocket message: user places BUY order → validate + submit
  ├── Redis response: price update for INFY → broadcast to subscribers
  ├── PostgreSQL response: order saved → send confirmation to user
  └── HTTP request: GET /api/portfolio → return cached data

  Phase 5: CHECK
  └── setImmediate: batch-write 50 price updates to TimescaleDB

  MICROTASKS (between each phase):
  ├── nextTick: emit 'order-confirmed' event
  └── Promise: resolve async order validation pipeline
Enter fullscreen mode Exit fullscreen mode
// real-world-loop.js — How these phases interact in a trading server
const http = require('http');
const Redis = require('ioredis');
const { Pool } = require('pg');

const redis = new Redis();
const db = new Pool({ connectionString: process.env.DATABASE_URL });

const priceBatch = [];

// TIMERS PHASE — scheduled recurring tasks
setInterval(async () => {
  // Fetch latest prices from exchange feed
  const prices = await exchangeAPI.getLatestPrices();

  // Publish to Redis (triggers I/O callbacks for subscribers)
  for (const price of prices) {
    await redis.publish(`price:${price.symbol}`, JSON.stringify(price));
    priceBatch.push(price);
  }
}, 1000);

// CHECK PHASE — batch writes using setImmediate
setInterval(() => {
  if (priceBatch.length > 0) {
    const batch = priceBatch.splice(0, priceBatch.length);

    // setImmediate ensures I/O from poll phase is processed first
    setImmediate(async () => {
      await batchInsertPrices(db, batch);
    });
  }
}, 5000);

// POLL PHASE — HTTP server handles incoming requests
const server = http.createServer(async (req, res) => {
  if (req.url === '/api/portfolio') {
    // This callback runs in the poll phase
    const portfolio = await getPortfolio(req.userId);

    // nextTick ensures response is sent before any other I/O
    process.nextTick(() => {
      res.writeHead(200, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify(portfolio));
    });
  }
});
Enter fullscreen mode Exit fullscreen mode

Common Mistakes (and How to Fix Them)

1. Blocking the Event Loop

The cardinal sin. If you block the main thread, nothing else runs — no timers, no I/O, no WebSocket messages.

// BAD — blocks the event loop for ~2 seconds
app.get('/api/report', (req, res) => {
  const data = fs.readFileSync('huge-file.csv');  // SYNCHRONOUS!
  const parsed = parseCSV(data);                  // CPU-intensive!
  res.json(parsed);
});

// GOOD — non-blocking with streaming
app.get('/api/report', (req, res) => {
  const stream = fs.createReadStream('huge-file.csv');
  stream.pipe(csvParser()).pipe(res);
});

// GOOD — offload CPU work to worker thread
const { Worker } = require('worker_threads');

app.get('/api/report', (req, res) => {
  const worker = new Worker('./csv-worker.js', {
    workerData: { file: 'huge-file.csv' },
  });
  worker.on('message', (result) => res.json(result));
  worker.on('error', (err) => res.status(500).json({ error: err.message }));
});
Enter fullscreen mode Exit fullscreen mode

2. Assuming setTimeout(fn, 0) Is Instant

// This does NOT run "immediately"
setTimeout(() => {
  console.log('This runs after ALL microtasks + at least 1ms delay');
}, 0);

// If you need "as soon as possible after I/O":
setImmediate(() => {
  console.log('This runs right after the current poll phase');
});

// If you need "before anything else async":
process.nextTick(() => {
  console.log('This runs before setTimeout, setImmediate, and I/O');
});
Enter fullscreen mode Exit fullscreen mode

3. Unhandled Promise Rejections

Unhandled rejections used to be silently swallowed. Since Node 15, they crash the process:

// BAD — unhandled rejection
async function fetchUser(id) {
  const res = await db.query('SELECT * FROM users WHERE id = $1', [id]);
  return res.rows[0]; // What if db.query throws?
}
fetchUser(123); // No .catch() → process crashes

// GOOD — always handle rejections
fetchUser(123).catch(err => console.error('Failed:', err));

// Or globally (as safety net, not primary handling):
process.on('unhandledRejection', (reason, promise) => {
  console.error('Unhandled Rejection at:', promise, 'reason:', reason);
  // Log and exit gracefully
});
Enter fullscreen mode Exit fullscreen mode

4. Not Understanding async/await and the Event Loop

await doesn't block the event loop — it yields control back to it:

// What actually happens with async/await:

async function handleRequest() {
  console.log('A');                    // Synchronous
  const data = await fetchFromDB();    // Yields to event loop here
  console.log('B');                    // Runs when promise resolves
  return data;
}

// Is equivalent to:
function handleRequest() {
  console.log('A');
  return fetchFromDB().then(data => {
    console.log('B');
    return data;
  });
}

// The event loop is FREE to handle other requests while awaiting
Enter fullscreen mode Exit fullscreen mode
  async/await UNDER THE HOOD
  ══════════════════════════

  handleRequest()
       │
       ▼
  console.log('A')          ← Synchronous, runs now
       │
       ▼
  await fetchFromDB()       ← Sends query, YIELDS to event loop
       │                       Event loop handles other work:
       │                       ├── WebSocket messages
       │                       ├── Other HTTP requests
       │                       ├── Timer callbacks
       │                       └── Price updates
       │
       ▼ (when DB responds)
  console.log('B')          ← Runs as microtask (promise resolution)
       │
       ▼
  return data
Enter fullscreen mode Exit fullscreen mode

How to Monitor Your Event Loop

If your event loop is being blocked or starved, your server slows down for everyone. Here's how to detect it:

// event-loop-monitor.js — Detect event loop delays
let lastCheck = Date.now();

setInterval(() => {
  const now = Date.now();
  const delay = now - lastCheck - 1000; // Expected: 0ms
  lastCheck = now;

  if (delay > 50) {
    console.warn(`Event loop delayed by ${delay}ms — potential blocking operation!`);
  }
}, 1000);

// For production: use the built-in perf_hooks
const { monitorEventLoopDelay } = require('perf_hooks');

const histogram = monitorEventLoopDelay({ resolution: 20 });
histogram.enable();

setInterval(() => {
  console.log({
    min: histogram.min / 1e6,    // Convert ns to ms
    max: histogram.max / 1e6,
    mean: histogram.mean / 1e6,
    p99: histogram.percentile(99) / 1e6,
  });
  histogram.reset();
}, 5000);
Enter fullscreen mode Exit fullscreen mode

When to Use What

  ┌──────────────────────┬──────────────────────────────────────┐
  │ API                  │ When to use                          │
  ├──────────────────────┼──────────────────────────────────────┤
  │ process.nextTick()   │ Emit events after constructor        │
  │                      │ returns. Ensure callback runs        │
  │                      │ before ANY I/O.                      │
  ├──────────────────────┼──────────────────────────────────────┤
  │ Promise / await      │ Async operations. Standard flow      │
  │                      │ control. 99% of the time, use this. │
  ├──────────────────────┼──────────────────────────────────────┤
  │ setTimeout(fn, 0)    │ Defer work to next loop iteration.   │
  │                      │ Give I/O a chance to breathe.        │
  ├──────────────────────┼──────────────────────────────────────┤
  │ setImmediate()       │ Run callback after I/O events in     │
  │                      │ current loop. Best for recursive     │
  │                      │ operations.                          │
  ├──────────────────────┼──────────────────────────────────────┤
  │ Worker Threads       │ CPU-intensive work (parsing, crypto, │
  │                      │ compression). Keep the event loop    │
  │                      │ free.                                │
  └──────────────────────┴──────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Key Takeaways

  • The event loop has 6 phases: Timers → Pending → Idle → Poll → Check → Close
  • Microtasks (nextTick, Promises) run between every phase — they have the highest priority
  • Poll phase is where Node spends most time — it blocks here waiting for I/O when idle
  • process.nextTick() runs before Promises, which run before setTimeout(0), which runs before setImmediate()
  • Inside an I/O callback, setImmediate() always runs before setTimeout(0) (deterministic)
  • Outside I/O callbacks, setTimeout(0) vs setImmediate() order is non-deterministic
  • Never block the event loop — use streams for large files, worker threads for CPU work
  • Recursive nextTick() starves I/O — use setImmediate() for recursive async patterns
  • Monitor your event loop — use perf_hooks.monitorEventLoopDelay() in production

Connect with Me

If this helped you finally understand the event loop, follow along — I post visual explainers on Node.js, system design, and AI engineering every week.

Next up: "I Built a RAG App That Chats With Any PDF — Here's How" — the full architecture, embedding pipeline, and code.

Top comments (0)