# The Node.js event loop
Node runs a synchronous script first, then repeatedly handles work scheduled by timers, I/O, and other APIs. JavaScript callbacks normally execute one at a time; libuv coordinates I/O so a program can wait for several operations without blocking on each one. A busy synchronous loop still blocks every other JavaScript callback.
# Phases
The official Node.js event-loop guide (opens new window) uses this conceptual phase cycle:
| Phase | What it handles | Example |
|---|---|---|
| timers | Timer callbacks whose threshold has elapsed | setTimeout, setInterval |
| pending callbacks | Selected I/O/system callbacks deferred to another iteration | Some TCP errors |
| idle, prepare | Internal setup | No user callbacks to schedule here |
| poll | New I/O and most ready I/O callbacks; may wait for work | fs.readFile callback |
| check | Callbacks queued for the check phase | setImmediate |
| close callbacks | Some abrupt/explicit close notifications | socket.on('close', ...) |
Each phase has its own responsibilities and usually a callback queue. A phase may have no callbacks; the loop can wait in poll and exits when nothing keeps the process alive. process.nextTick() and Promise/queueMicrotask() callbacks are not extra libuv phases: they run at microtask checkpoints around callback execution. Typically the next-tick queue is drained before Promise microtasks in CommonJS callbacks; ESM top-level evaluation has different initialization timing, so do not rely on this as a universal cross-context ordering rule. Recursive next-tick scheduling can starve I/O.
# Follow one illustrative trace
This CommonJS example schedules the timer and immediate inside the file callback, avoiding the non-guaranteed top-level race between them:
const fs = require('node:fs');
console.log('start');
fs.readFile(__filename, () => {
console.log('I/O');
process.nextTick(() => console.log('nextTick'));
Promise.resolve().then(() => console.log('promise'));
setImmediate(() => console.log('immediate'));
setTimeout(() => console.log('timer'), 0);
});
console.log('end');
The following guided illustration, not a live Node scheduler, traces one plausible run: synchronous output, poll callback, nextTick and Promise continuations, check, then a timer on a later loop pass. The internal and empty phases are shown so they are not mistaken for missing phases.
If the interactive controls are unavailable, trace the sample manually: start → end → I/O → nextTick → promise → immediate → timer (the last two belong to check and a later timers pass in the illustrated run).
# Ordering that matters
setTimeout(fn, 0)gives a minimum timer threshold, not an immediate interrupt. A long synchronous task delays it regardless of how small the delay is.- At top level,
setTimeout(fn, 0)versussetImmediate(fn)can vary by context and timing. Inside an I/O callback,setImmediateusually runs in check before the next timer pass. - Starting with Node 20 / libuv 1.45, timers are processed after poll rather than both before and after poll as in older versions. A simplified phase chart describes which queues receive callbacks, not a promise of identical output on every Node release.
await/Promise continuations are microtasks, not poll or check callbacks. An async filesystem operation completes outside the JavaScript call stack; its callback resumes on the loop.
Try running the sample on your installed Node version, predict the output before each run, and compare it with the visualizer. Do not use a long busy loop as a performance benchmark.