Skip to content
Node.js fundamentals 3 min read

The Event Loop

The event loop is how Node achieves non-blocking concurrency on a single thread. It continuously checks for completed work and runs the callbacks waiting on it. Understanding its phases—and the precise ordering of timers, microtasks, and nextTick—separates engineers who guess at async behavior from those who can reason about it.

How the loop works

When your script finishes its synchronous top-level code, Node enters the event loop. libuv drives the loop through a fixed sequence of phases, each with its own callback queue. The loop runs a phase to completion, then moves to the next, wrapping around indefinitely until there’s nothing left to do.

The phases

PhaseWhat runs here
TimersCallbacks from setTimeout and setInterval whose time has elapsed.
Pending callbacksDeferred I/O callbacks (e.g. certain TCP errors).
PollRetrieves new I/O events; executes most I/O callbacks. Blocks here when idle.
ChecksetImmediate callbacks.
CloseClose events like socket.on("close").

The poll phase is where the loop spends most of its time, waiting for and processing I/O. If timers are due, it wraps back to the timers phase; if setImmediate callbacks are queued, it advances to check.

This phase order is a libuv implementation detail you can rely on: timers come before check, which comes before close, on every single tick.

Microtasks vs macrotasks

Two special queues drain between every phase transition, not as phases themselves:

  • process.nextTick queue — highest priority, drained first.
  • Microtask queue (Promises) — drained next, after nextTick.

Everything scheduled on the loop’s phases (timers, I/O, setImmediate) is a macrotask. After each macrotask, Node fully empties the nextTick queue, then the Promise microtask queue, before continuing.

console.log("sync 1");

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

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

console.log("sync 2");

Output:

sync 1
sync 2
nextTick
promise
timeout
immediate

Synchronous code runs first. Then the loop drains nextTick before Promise microtasks. Only afterward do the macrotask phases run—timers before check.

nextTick vs setImmediate vs setTimeout

setTimeout(() => console.log("setTimeout"), 0);
setImmediate(() => console.log("setImmediate"));
process.nextTick(() => console.log("nextTick"));

Output:

nextTick
setTimeout
setImmediate
  • process.nextTick runs after the current operation, before the loop continues—even before Promises.
  • setTimeout(fn, 0) runs in the timers phase on a later tick.
  • setImmediate runs in the check phase, after poll.

The setTimeout vs setImmediate order is deterministic only inside an I/O callback, where setImmediate always wins. At the top level, it depends on timer setup overhead and may vary.

Inside an I/O callback the order is reliable:

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

Output:

immediate
timeout

After the poll phase delivers the file callback, the loop heads straight to check (setImmediate) before wrapping back to timers.

Best Practices

  • Don’t starve the loop: recursively queuing process.nextTick can block I/O entirely. Prefer setImmediate for deferral.
  • Keep synchronous work short; long loops block every pending callback.
  • Offload CPU-bound tasks to Worker Threads so the loop stays responsive.
  • Avoid relying on top-level setTimeout vs setImmediate ordering—it isn’t guaranteed.
  • Treat unhandled Promise rejections seriously; attach .catch or use try/catch with await.
Last updated June 1, 2026
Was this helpful?