DEV Community

Alex Chen
Alex Chen

Posted on

Node.js Event Loop: A Visual Guide to Async Programming (2026)

Node.js Event Loop: A Visual Guide to Async Programming (2026)

The event loop is what makes Node.js fast. Understanding it means writing faster, bug-free async code.

The Big Picture

┌───────────────────────────────────┐
│            Your Code              │  ← JavaScript (single thread!)
│         (synchronous)             │
└─────────────┬─────────────────────┘
              │ async operations
              ▼
┌───────────────────────────────────┐
│        Node.js APIs               │  ← C++ (multi-threaded!)
│  fs, crypto, dns, http, zlib...   │
└─────────────┬─────────────────────┘
              │ callback/task queued
              ▼
┌───────────────────────────────────┐
│         Event Loop                │
│                                   │
│  ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │
│  │timers│ │pending│ │idle │ │poll │ │
│  └─────┘ └─────┘ └─────┘ └─────┘ │
│                                   │
│  Runs callbacks from each phase   │
│  in order, one at a time          │
└───────────────────────────────────┘

Key insight: Node.js is single-threaded for JS code,
but the underlying C++ APIs use thread pools for I/O!
Enter fullscreen mode Exit fullscreen mode

The 6 Phases of the Event Loop

// Each iteration of the event loop runs through these phases:

// Phase 1: Timers (setTimeout, setInterval)
// → Callbacks whose timer has expired
setTimeout(() => console.log('timer1'), 0);
setTimeout(() => console.log('timer2'), 10);

// Phase 2: Pending Callbacks
// → I/O callbacks deferred from previous loop iteration
// (mostly internal Node.js use — you rarely interact directly)

// Phase 3: Idle/Prepare
// → Internal use only (you can ignore this)

// Phase 4: Poll (THE MOST IMPORTANT PHASE!)
// → Retrieves new I/O events
// → Executes I/O callbacks (fs.read, network responses, etc.)
// → Blocks here if there's nothing else to do

// Phase 5: Check (setImmediate)
// → setImmediate() callbacks run HERE (after I/O)
setImmediate(() => console.log('immediate'));

// Phase 6: Close Callbacks
// → socket.on('close'), stream.on('close') etc.
Enter fullscreen mode Exit fullscreen mode

Execution Order: What Runs First?

// THE CLASSIC INTERVIEW QUESTION:
// In what order do these log?

console.log('start');                    // 1. Synchronous code first

setTimeout(() => console.log('timeout'), 0);   // 4. Timer phase (next tick)

setImmediate(() => console.log('immediate')); // 5. Check phase (after poll)

Promise.resolve().then(() => {
  console.log('promise');                 // 2. Microtask queue (after sync)
});

process.nextTick(() => {
  console.log('nextTick');               // 3. Next tick queue (before promise!)
});

fs.readFile(__filename, () => {
  console.log('file read');             // 6. Poll phase (I/O callback)
});

// Output order:
// start → nextTick → promise → timeout/immediate (order varies!) → file read
//
// Why timeout/immediate order varies?
// It depends on which event loop cycle they land in.
// If both are ready on the SAME cycle: timers run before check (usually).
// But if the loop is already past timers when immediate is queued...
// DON'T rely on the order between setTimeout(0) and setImmediate!

// RELIABLE ORDERING:
// nextTick > Promise > (setTimeout ≈ setImmediate, don't depend on it) > I/O
Enter fullscreen mode Exit fullscreen mode

Microtasks vs Macrotasks

// Microtask queues (drain AFTER each macrotask):
// - process.nextTick()
// - Promise.then/catch/finally
// - queueMicrotask()

// Macrotask (each triggers a full event loop iteration):
// - setTimeout / setInterval
// - setImmediate
// - I/O callbacks
// - UI rendering (browser)

// CRITICAL: Microtasks can STARVE the event loop!
function badInfiniteLoop() {
  // This NEVER yields control back to the event loop
  return Promise.resolve().then(() => badInfiniteLoop());
}
// Result: No timers fire, no I/O handled, server freezes!
// Fix: Use setImmediate or setTimeout to yield:

function goodYield() {
  return new Promise(resolve => {
    setImmediate(() => {  // Yields to event loop between iterations
      goodYield();
      resolve();
    });
  });
}

// Practical rule:
// nextTick/Promise = "do this ASAP but after current sync code"
// setImmediate/setTimeout = "do this on next event loop cycle"
Enter fullscreen mode Exit fullscreen mode

Blocking the Event Loop

// ❌ BLOCKING: CPU-intensive operation freezes everything
app.get('/compute', (req, res) => {
  const result = heavyComputation(data); // Takes 5 seconds
  res.json(result);
});
// During those 5 seconds: NO other requests are handled!
// Server appears frozen.

// ✅ Solution 1: Offload to worker threads
const { Worker } = require('worker_threads');

app.get('/compute', (req, res) => {
  const worker = new Worker('./compute-worker.js', { workerData: data });
  worker.on('message', result => res.json(result));
  worker.on('error', err => res.status(500).send(err.message));
});

// compute-worker.js:
const { parentPort, workerData } = require('worker_threads');
const result = heavyComputation(workerData);
parentPort.postMessage(result);

// ✅ Solution 2: Break into chunks with setImmediate
function processLargeArray(array, chunkSize, processor) {
  let index = 0;

  function processChunk() {
    const chunk = array.slice(index, index + chunkSize);
    chunk.forEach(processor);
    index += chunkSize;

    if (index < array.length) {
      setImmediate(processChunk); // Yield between chunks
    } else {
      console.log('Done!');
    }
  }

  processChunk();
}

// ✅ Solution 3: Use child_process.fork (separate Node process)
const { fork } = require('child_process');
const child = fork('heavy-task.js');
child.send(data);
child.on('message', result => res.json(result));
Enter fullscreen mode Exit fullscreen mode

Async/Await Under the Hood

// What actually happens when you use async/await?

async function getUser(id) {
  const user = await db.query('SELECT * FROM users WHERE id = ?', [id]);
  const posts = await db.query('SELECT * FROM posts WHERE user_id = ?', [id]);
  return { user, posts };
}

// Roughly equivalent to:
function getUser(id) {
  return db.query('SELECT * FROM users WHERE id = ?', [id])
    .then(user => {
      return db.query('SELECT * FROM posts WHERE user_id = ?', [id])
        .then(posts => ({ user, posts }));
    });
}

// Key points:
// 1. await splits execution into separate microtask callbacks
// 2. Each await yields to the event loop (other code CAN run between awaits)
// 3. Error handling: try/catch becomes .catch()

// ⚠️ Common mistake: Sequential awaits when parallel would be faster
async function slow() {
  const user = await fetchUser(id);     // Wait 100ms
  const posts = await fetchPosts(id);   // Wait another 100ms
  const comments = await fetchComments(id); // Wait another 100ms
  // Total: ~300ms
}

async function fast() {
  const [user, posts, comments] = await Promise.all([
    fetchUser(id),       // All start simultaneously
    fetchPosts(id),
    fetchComments(id),
  ]);
  // Total: ~100ms (longest single request)
}

// Rule of thumb: If awaits don't depend on each other → Promise.all them!
Enter fullscreen mode Exit fullscreen mode

Streams: Backpressure & Memory

// Problem: Reading a 10GB file into memory crashes your server
const data = fs.readFileSync('huge-file.json'); // ❌ 10GB in RAM!

// Solution: Streams (process data in small chunks)
const stream = fs.createReadStream('huge-file.json', { highWaterMark: 64 * 1024 }); // 64KB chunks

let chunkCount = 0;
stream.on('data', (chunk) => {
  chunkCount++;
  processChunk(chunk); // Process 64KB at a time
  // Memory stays constant regardless of file size!
});

stream.on('end', () => {
  console.log(`Done! ${chunkCount} chunks processed`);
});

stream.on('error', (err) => {
  console.error('Stream error:', err);
});

// Pipe pattern (connect streams together):
const readStream = fs.createReadStream('input.txt');
const gzip = createGzip();
const writeStream = fs.createWriteStream('output.txt.gz');

readStream.pipe(gzip).pipe(writeStream);
// Automatic backpressure: if writeStream is slow,
// readStream automatically slows down!

// Stream helpers (Node.js 18+):
import { pipeline } from 'stream/promises';
await pipeline(
  fs.createReadStream('input.csv'),
  new Transform({
    transform(chunk, encoding, callback) {
      // Transform each chunk
      callback(null, chunk.toString().toUpperCase());
    }
  }),
  fs.createWriteStream('output-uppercase.csv')
);
// Handles errors, cleanup, and backpressure automatically!
Enter fullscreen mode Exit fullscreen mode

Practical Debugging

// Is your event loop blocked? Measure it:

const start = process.hrtime.bigint();

setInterval(() => {
  const now = process.hrtime.bigint();
  const ms = Number(now - start) / 1_000_000; // Convert nanoseconds to ms

  if (ms > 100) {
    console.warn(`Event loop delay: ${ms.toFixed(0)}ms (>100ms = blocked!)`);
  }

  // Reset for next measurement
  Object.assign(start, process.hrtime.bigint());
}, 1000); // Check every second

// Or use a library:
// - blocked-at (npm): Detects blocking automatically
// - clinic.js (npm): Full performance profiling

// Memory leak detection:
setInterval(() => {
  const used = process.memoryUsage();
  console.log(
    `RSS: ${(used.heapUsed / 1024 / 1024).toFixed(0)}MB | ` +
    `Heap: ${(used.heapTotal / 1024 / 1024).toFixed(0)}MB`
  );
}, 5000);
// If RSS keeps growing → memory leak somewhere!
Enter fullscreen mode Exit fullscreen mode

What part of the event loop confuses you most? Share your questions!

Follow @armorbreak for more practical developer guides.

Top comments (0)