Lesson 2 of 8
5 min read
Advanced Node.js

Event Loop Deep Dive

Understand the internals of Node.js event loop, phases, and execution order

What is the Event Loop?

The event loop is what allows Node.js to perform non-blocking I/O operations despite JavaScript being single-threaded. It offloads operations to the system kernel whenever possible and handles callbacks when operations complete.

🔄 Event Loop Phases

1
Timers - Execute setTimeout() and setInterval() callbacks
2
Pending callbacks - I/O callbacks deferred from previous loop
3
Idle, prepare - Internal use only
4
Poll - Retrieve new I/O events, execute I/O callbacks
5
Check - Execute setImmediate() callbacks
6
Close callbacks - socket.on('close'), etc.

Execution Order Example

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
// 6: Script end
// 5: nextTick      (microtask queue - highest priority)
// 4: Promise       (microtask queue)
// 2: setTimeout    (timers phase)
// 3: setImmediate  (check phase)

Microtasks vs Macrotasks

Microtasks (High Priority) Macrotasks (Lower Priority)
process.nextTick() setTimeout()
Promise.then/catch/finally setInterval()
queueMicrotask() setImmediate()
I/O operations

Microtasks are processed after each phase, before moving to the next phase. All pending microtasks are drained before continuing.

process.nextTick() vs setImmediate()

// process.nextTick - runs BEFORE the event loop continues
// setImmediate - runs in the CHECK phase of the event loop

// Inside an I/O callback, setImmediate runs first
const fs = require('fs');

fs.readFile('file.txt', () => {
  setTimeout(() => console.log('timeout'), 0);
  setImmediate(() => console.log('immediate'));
});
// Output: immediate, timeout

// Outside I/O, order is non-deterministic
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
// Output: could be either order!

// nextTick always runs first
process.nextTick(() => console.log('nextTick'));
Promise.resolve().then(() => console.log('promise'));
// Output: nextTick, promise

Blocking the Event Loop

⚠️ Long-running synchronous code blocks the entire event loop!

// BAD: Blocks the event loop
function heavyComputation() {
  let sum = 0;
  for (let i = 0; i < 1e9; i++) {
    sum += i;
  }
  return sum;
}

// All requests are blocked during computation
const http = require('http');
http.createServer((req, res) => {
  const result = heavyComputation();  // Blocks for seconds!
  res.end(String(result));
}).listen(3000);

// BETTER: Break work into chunks
function heavyComputationAsync(callback) {
  let sum = 0;
  let i = 0;
  const batchSize = 1e6;

  function processBatch() {
    const end = Math.min(i + batchSize, 1e9);
    while (i < end) {
      sum += i++;
    }

    if (i < 1e9) {
      // Yield to event loop
      setImmediate(processBatch);
    } else {
      callback(sum);
    }
  }

  processBatch();
}

// BEST: Use Worker Threads (next lesson!)

Async Patterns and the Event Loop

// Understanding async execution order
async function example() {
  console.log('1: async start');
  
  await Promise.resolve();
  console.log('2: after await');
  
  return 'done';
}

console.log('3: before call');
example().then(r => console.log('4:', r));
console.log('5: after call');

// Output:
// 3: before call
// 1: async start
// 5: after call
// 2: after await
// 4: done

// Why? 'await' schedules continuation as a microtask

Event Loop Monitoring

// Measure event loop lag
let lastCheck = Date.now();

setInterval(() => {
  const now = Date.now();
  const lag = now - lastCheck - 1000;  // Expected 1000ms
  
  if (lag > 100) {
    console.warn('Event loop lag:', lag, 'ms');
  }
  
  lastCheck = now;
}, 1000);

// Using monitorEventLoopDelay (Node.js 11+)
const { monitorEventLoopDelay } = require('perf_hooks');

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

setInterval(() => {
  console.log('Event loop delay:');
  console.log('  Min:', histogram.min / 1e6, 'ms');
  console.log('  Max:', histogram.max / 1e6, 'ms');
  console.log('  Mean:', histogram.mean / 1e6, 'ms');
  console.log('  P99:', histogram.percentile(99) / 1e6, 'ms');
  histogram.reset();
}, 5000);

Common Pitfalls

// 1. Recursive nextTick can starve the event loop
function badRecursion() {
  process.nextTick(badRecursion);  // Never yields!
}

// 2. Creating too many timers
// BAD
for (let i = 0; i < 10000; i++) {
  setTimeout(() => {}, Math.random() * 1000);
}
// BETTER: Use a scheduling library or batch operations

// 3. Mixing sync and async in callbacks
function problematic(callback) {
  if (cache[key]) {
    callback(cache[key]);  // Sync!
  } else {
    fetchData(key, callback);  // Async!
  }
}

// FIXED: Always be async
function fixed(callback) {
  if (cache[key]) {
    process.nextTick(() => callback(cache[key]));
  } else {
    fetchData(key, callback);
  }
}

💡 Key Takeaways

  • • The event loop processes phases in order: timers → poll → check → close
  • • Microtasks (nextTick, Promises) run between each phase
  • • process.nextTick() has higher priority than Promises
  • • Never block the event loop with synchronous operations
  • • Use setImmediate() to yield to the event loop

Continue Learning