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
| Phase | What runs here |
|---|---|
| Timers | Callbacks from setTimeout and setInterval whose time has elapsed. |
| Pending callbacks | Deferred I/O callbacks (e.g. certain TCP errors). |
| Poll | Retrieves new I/O events; executes most I/O callbacks. Blocks here when idle. |
| Check | setImmediate callbacks. |
| Close | Close 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.nextTickqueue — 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.nextTickruns after the current operation, before the loop continues—even before Promises.setTimeout(fn, 0)runs in the timers phase on a later tick.setImmediateruns in the check phase, after poll.
The
setTimeoutvssetImmediateorder is deterministic only inside an I/O callback, wheresetImmediatealways 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.nextTickcan block I/O entirely. PrefersetImmediatefor 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
setTimeoutvssetImmediateordering—it isn’t guaranteed. - Treat unhandled Promise rejections seriously; attach
.catchor usetry/catchwithawait.