Event Loop Deep Dive

intermediate event-loop libuv async microtasks nexttick

The event loop is THE most asked Node.js interview question. In simple language — it’s the mechanism that lets a single-threaded runtime handle thousands of concurrent operations without blocking.

When we call setTimeout, fs.readFile, or a network request, Node hands the work to libuv and our callback gets queued. The event loop is the orchestrator that picks the right callback to run next.

The 6 phases

The event loop runs in a loop (obviously), and each tick of that loop goes through 6 phases in order. Each phase has its own callback queue.

One tick of the event loop
1timers — setTimeout, setInterval callbacks whose time is up
2pending callbacks — some system errors (e.g. TCP ECONNREFUSED)
3idle, prepare — internal only
4poll — I/O callbacks (fs, net, ...). Waits here if nothing else to do.
5check — setImmediate callbacks
6close callbacks — socket.on('close'), etc.
↻ loops back to phase 1

The four we actually care about in interviews are timers, poll, check, and close.

Microtasks run BETWEEN phases

Here’s the key bit most people miss. Microtasks aren’t a phase. They run between every phase (and after every individual callback). There are two microtask queues:

  • process.nextTick queue — runs first, drained completely
  • Promise (then/catch/finally) queue — runs second, drained completely

So the real flow is: run a callback → drain nextTick queue → drain Promise queue → next callback. This is why process.nextTick can starve the event loop if we recurse on it.

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

Promise.resolve().then(() => console.log("promise"));
process.nextTick(() => console.log("nextTick"));

console.log("sync");

// Output:
// sync
// nextTick
// promise
// timeout       (or immediate first — depends on context)
// immediate

setImmediate vs setTimeout(fn, 0)

Classic interview trap. Outside of an I/O callback, the order is non-deterministic — it depends on how fast Node enters the loop. Inside an I/O callback, setImmediate ALWAYS runs first (because we’re already past the timers phase, heading to check).

const fs = require("node:fs");

fs.readFile(__filename, () => {
  setTimeout(() => console.log("timeout"), 0);
  setImmediate(() => console.log("immediate"));
});
// Always: immediate, timeout

process.nextTick — use carefully

process.nextTick fires before any I/O or timer, after the current operation. It’s how we defer something to “right after this function but before anything else”.

Think of it like — “finish this stack, then immediately do this, before going back to the event loop”.

Recursive nextTick calls can block the loop forever. Recursive Promise resolutions have the same risk in newer Node (they share priority since Node 11+).

Why Node feels concurrent

While our JS is sync and single-threaded, libuv runs I/O on a 4-thread worker pool by default (configurable via UV_THREADPOOL_SIZE). File system ops, DNS lookups, and crypto use this pool. Network I/O uses the OS’s epoll/kqueue directly — no thread needed.

The thread pool finishes a job → pushes the callback into the right phase queue → event loop picks it up on the next tick.

Practical takeaway

  • Don’t do heavy CPU work on the main thread — it blocks every phase.
  • process.nextTick for “must run before any I/O”.
  • setImmediate for “run after current poll phase”.
  • queueMicrotask is the standard, cross-platform way to schedule a microtask (uses the Promise queue).
  • If our loop is lagging, check for sync code, big JSON.parse, or sync fs calls.