Kulpost BlogNotes and write-ups

← All posts

Understanding the Node.js Event Loop

2026-10-30 · Kuligaposten

Understanding the Node.js Event Loop

Node.js runs your JavaScript on a single thread, yet it can handle thousands of concurrent connections without blocking. The event loop is the mechanism that makes that possible — it's what lets Node hand off slow work (file I/O, network requests, timers) and keep running other code while it waits.

The synchronous code runs first

Nothing in the event loop runs until the current synchronous script finishes. This trips people up constantly:

JavaScript
console.log('start');

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

console.log('end');

// Logs: start, end, timeout

Even with a 0ms delay, setTimeout always runs after the synchronous code, because the callback is scheduled on the event loop, not executed immediately.

Microtasks vs macrotasks

The event loop has two queues that matter most in everyday code:

  • Microtasks: Promise callbacks (.then, async/await), queueMicrotask.
  • Macrotasks: setTimeout, setInterval, I/O callbacks.

After every single macrotask, Node drains the entire microtask queue before moving on. That means promises consistently jump ahead of timers:

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

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

console.log('sync');

// Logs: sync, promise, timeout

The phases of the loop

Node's event loop (via libuv) cycles through phases in order, each with its own queue:

  1. Timers — setTimeout / setInterval callbacks whose time has elapsed.
  2. Pending callbacks — some system-level callbacks deferred from the previous cycle.
  3. Poll — retrieves new I/O events; executes I/O callbacks.
  4. Check — setImmediate callbacks.
  5. Close callbacks — e.g. socket.on('close', ...).

Microtasks (promises) run between every phase, not just at the end of a full cycle.

setImmediate vs setTimeout(fn, 0)

Both look similar but fire in a different phase:

JavaScript
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));

At the top level, the order between these two isn't guaranteed — it depends on process startup timing. But inside an I/O callback, setImmediate always runs first, because the poll phase leads directly into the check phase:

JavaScript
const fs = require('fs');

fs.readFile(__filename, () => {
  setTimeout(() => console.log('timeout'), 0);
  setImmediate(() => console.log('immediate'));
});

// Logs: immediate, timeout

Why this matters

Blocking the event loop with expensive synchronous work — a huge JSON parse, a tight CPU-bound loop — freezes every other request Node is handling, because there's only one thread running JavaScript. For CPU-heavy work, offload it to a worker_thread or a separate process instead of running it inline.

Comments 0

← All posts