All posts
PerformanceApr 22, 2026·8 min read

Understanding the Node.js Event Loop

Phases, microtasks vs macrotasks, setImmediate vs setTimeout(0), and why blocking the event loop is the most dangerous thing you can do in a Node.js server.

The event loop in one sentence

Node.js runs JavaScript on a single thread. The event loop is the mechanism that allows it to handle concurrent I/O operations without blocking: when an async operation completes, its callback is queued. The event loop processes these callbacks in order.

The six phases

The event loop cycles through six phases. Each phase has a queue of callbacks to execute before moving to the next:

  1. timerssetTimeout and setInterval callbacks whose delay has passed
  2. pending callbacks — I/O error callbacks deferred from the previous cycle
  3. idle, prepare — Internal use only
  4. poll — Retrieve new I/O events, execute I/O callbacks. The event loop blocks here when idle.
  5. checksetImmediate callbacks
  6. close callbacks — Cleanup callbacks like socket.on('close')

Microtasks run between phases

Promise callbacks (.then, async/await) and queueMicrotask are microtasks. They run after each phase completes and before the next phase begins. This means a loop that continuously creates resolved promises can starve the event loop.

// Microtask queue drains completely before moving to next phase
Promise.resolve().then(() => console.log('microtask 1'));
setImmediate(() => console.log('check phase'));
Promise.resolve().then(() => console.log('microtask 2'));

// Output: microtask 1, microtask 2, check phase

setTimeout(0) vs setImmediate

// When called at the top level, order is non-deterministic:
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));

// When called inside an I/O callback, setImmediate always wins:
fs.readFile('file.txt', () => {
  setTimeout(() => console.log('timeout'), 0);
  setImmediate(() => console.log('immediate')); // Always first
});

Blocking the event loop

Any synchronous code that runs for more than a few milliseconds blocks every other request from being served during that time. Common culprits:

// ✗ Blocks the event loop for duration of computation
app.get('/parse', (req, res) => {
  const result = JSON.parse(hugeJsonString); // 200ms on large payload
  res.json(result);
});

// ✓ Move to a worker thread
import { Worker } from 'worker_threads';
app.get('/parse', async (req, res) => {
  const result = await runInWorker('./json-parser-worker.js', hugeJsonString);
  res.json(result);
});

Measuring event loop lag

// Measure event loop delay
import { monitorEventLoopDelay } from 'perf_hooks';

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

setInterval(() => {
  log.info({
    mean: histogram.mean / 1e6,   // Convert nanoseconds to ms
    p99: histogram.percentile(99) / 1e6,
  }, 'Event loop delay');
  histogram.reset();
}, 10000);

A healthy event loop has p99 delay under 10ms. Over 100ms and you have a blocking operation causing user-visible latency.

Ready to put this into practice?

Deploy your Node.js app to production in minutes — zero YAML, automatic CI/CD, and HTTPS included.