DEV Community

Alex Chen
Alex Chen

Posted on

Node.js Event Loop: Understanding the Async Heartbeat (2026)

Node.js Event Loop: Understanding the Async Heartbeat (2026)

The event loop is what makes Node.js "async" and "non-blocking". Understanding it isn't academic — it's the difference between an app that handles 10 connections and one that handles 10,000.

The Mental Model

Think of the event loop as a loop that keeps checking:

┌─────────────────────────────────────┐
│  1. Run any TIMERS (setTimeout, etc) │
│  2. Run PENDING CALLBACKS           │
│  3. POLL for new I/O events         │
│  4. Run SET IMMEDIATE callbacks     │
│  5. CLOSE callbacks                 │
│  → Back to step 1                   │
└─────────────────────────────────────┘

Key insight: JavaScript is SINGLE-THREADED.
The event loop is just a clever scheduling mechanism.
Only ONE thing runs at a time.
"Async" means "I'll come back to this later when data arrives",
not "this runs on another thread".
Enter fullscreen mode Exit fullscreen mode

The 6 Phases in Detail

// Each iteration of the event loop has 6 phases:
// Timers → Pending Callbacks → Idle/Prepare → Poll → Check → Close Callbacks

// Phase 1: TIMERS
// setTimeout and setInterval callbacks run here
// ⚠️ NOT guaranteed to run at exact time! Just "no earlier than"
console.log('Start');
setTimeout(() => console.log('Timer: 0ms'), 0);
setTimeout(() => console.log('Timer: 100ms'), 100);
console.log('End');
// Output: Start → End → Timer: 0ms → Timer: 100ms
// Even 0ms timer doesn't run immediately!

// Phase 2: PENDING CALLBACKS
// I/O error callbacks from previous loop iterations
// Usually handled by system internals

// Phase 3: IDLE/PREPARE
// Internal use only (libuv internals)
// prepare: run setImmediate() callbacks scheduled here

// Phase 4: POLL (the most important phase!)
// Retrieves new I/O events
// - If poll queue not empty → process callbacks
// - If empty AND no timers → wait for I/O (may block here!)
// - If empty BUT has timers → move to next phase (don't block)

// Phase 5: CHECK (setImmediate)
// Runs setImmediate() callbacks
// This is why setImmediate beats setTimeout(fn, 0):
setImmediate(() => console.log('immediate'));
setTimeout(() => console.log('timeout'), 0);
// Output: immediate → timeout (always! in same callback context)

// Phase 6: CLOSE CALLBACKS
// socket.on('close', ...) etc.
Enter fullscreen mode Exit fullscreen mode

Microtasks vs Macrotasks

// CRITICAL distinction that affects execution order:

// MACROTASKS (run between event loop phases):
// setTimeout, setInterval, setImmediate, I/O callbacks, UI rendering

// MICROTASKS (run after each macrotask completes, before next macrotask):
// Promise.then/.catch/.finally, queueMicrotask(), MutationObserver

// Execution order example:
console.log('1: script start');

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

Promise.resolve()
  .then(() => console.log('3: promise 1'))
  .then(() => console.log('4: promise 2'));

queueMicrotask(() => console.log('5: microtask'));

console.log('6: script end');

// Output order:
// 1: script start        ← synchronous code first
// 6: script end           ← synchronous code finishes
// 3: promise 1            ← microtask queue drains completely
// 5: microtask            ← before next macrotask
// 4: promise 2            ← still draining microtasks!
// 2: setTimeout          ← now macrotask runs

// ⚠️ Microtask infinite loop blocks EVERYTHING:
Promise.resolve().then(async function loop() {
  console.log('stuck in microtask!');
  await Promise.resolve(); // Still a microtask!
  return loop(); // Never lets event loop continue!
});
// No timers fire, no I/O processes, nothing else works!
Enter fullscreen mode Exit fullscreen mode

Practical Implications

// Problem #1: Blocking the event loop
function blockingOperation() {
  // CPU-intensive work blocks everything:
  const start = Date.now();
  while (Date.now() - start < 5000) {
    // Spinning CPU for 5 seconds
    // During this time: NO requests handled, NO timers fire, NOTHING
  }
}
// Fix: Use Worker threads or break into chunks with setImmediate

// Fix: Yield control periodically
function nonBlockingWork(items, callback) {
  let index = 0;

  function processChunk() {
    const chunkEnd = Math.min(index + 1000, items.length);
    for (; index < chunkEnd; index++) {
      heavyProcessing(items[index]);
    }

    if (index < items.length) {
      setImmediate(processChunk); // Let other things run between chunks
    } else {
      callback();
    }
  }

  processChunk();
}

// Problem #2: Starving I/O with CPU work
// If you have constant computation, I/O callbacks never get to run!
// Solution: Worker threads for CPU work:
const { Worker } = require('worker_threads');
const worker = new Worker('./heavy-computation.js', { workerData: largeDataset });
worker.on('message', (result) => {
  console.log('Result:', result); // Main thread stays responsive!
});

// Problem #3: Unhandled rejections in microtasks
// A rejected promise without .catch() creates a "unhandled rejection"
// But it's reported asynchronously (via microtask), which can be confusing:
Promise.reject(new Error('oops'));
console.log('This prints BEFORE the unhandled rejection warning');
// Because the rejection goes through microtask queue first

// Best practice: Always handle errors at each level:
async function safeAPICall() {
  try {
    const data = await fetch('/api/data');
    return await data.json();
  } catch (err) {
    console.error('API call failed:', err.message);
    throw err; // Re-throw if caller needs to know
  }
}

// Problem #4: process.nextTick vs setImmediate vs setTimeout
process.nextTick(() => console.log('nextTick'));   // Before all phases (microtask-like)
setImmediate(() => console.log('setImmediate'));      // After poll phase
setTimeout(() => console.log('setTimeout 0'), 0);     // After timers phase

// Order: nextTick → setImmediate → setTimeout(0)
// Use nextTick for: cleanup after current operation (must run before I/O)
// Use setImmediate for: "after I/O but before timers" operations
// Use setTimeout for: actual delayed execution (minimum 1ms even if 0)
Enter fullscreen mode Exit fullscreen mode

Debugging Event Loop Issues

// Detect if your event loop is blocked:
let lastTime = process.hrtime.bigint();
const BLOCK_THRESHOLD_MS = 100; // Alert if loop blocked > 100ms

function checkLoopDelay() {
  const now = process.hrtime.bigint();
  const delayMs = Number(now - lastTime) / 1_000_000;
  if (delayMs > BLOCK_THRESHOLD_MS) {
    console.warn(`Event loop blocked for ${delayMs.toFixed(0)}ms`);
  }
  lastTime = now;
}

// Run this check frequently via interval:
setInterval(checkLoopDelay, 1000);

// Or use built-in diagnostics:
// --enable-source-maps flag for better async stack traces
// NODE_DEBUG=async_hooks node app.js  // See internal async operations

// Performance hooks (Node.js 12+):
const { performance, PerformanceObserver } = require('perf_hooks');

const obs = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.entryType === 'node') {
      console.log(`${entry.name}: ${entry.duration}ms`);
    }
  }
});

obs.observe({ entryTypes: ['node'] });

// Measure specific async operations:
performance.mark('db-query-start');
await db.query('SELECT * FROM users');
performance.mark('db-query-end');
performance.measure('database query', 'db-query-start', 'db-query-end');
Enter fullscreen mode Exit fullscreen mode

What's the most confusing aspect of the event loop? Have you ever debugged a blocking issue?

Follow @armorbreak for more practical developer guides.

Top comments (0)