Understanding the Node.js Event Loop
I walk through libuv's six phases and show exactly when process.nextTick(), setImmediate(), and promise callbacks run in the Node.js event loop.

Here’s a quick puzzle. Run this code in Node.js:
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
The output isn’t stable. Sometimes timeout prints first, sometimes immediate does. Now wrap those same two calls inside a file-read callback:
const fs = require('node:fs');
fs.readFile(__filename, () => {
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
});
Now the order is always immediate then timeout. Every single time, no exceptions.
That’s not a quirk or a race condition — it’s the direct result of how libuv’s phase structure works. The event loop that runs in a browser and the one Node.js uses are built differently, and that difference explains exactly why setImmediate wins inside an I/O callback and can’t guarantee anything outside one.
This post covers the Node.js-specific model. The browser’s event loop — task queues and microtasks — is a different architecture covered in Promises, Async/Await, and the Event Loop. If you haven’t read that post, the model here will still make sense on its own.
Series: Node.js deep-dive, part 2 of 6.
On this page
- Why Node.js needs libuv
- The six libuv phases
- The poll phase: where the loop actually waits
- process.nextTick() and microtasks: the inter-phase queues
- Why setImmediate() reliably wins inside I/O callbacks
- What blocks the loop — and what doesn’t
Why Node.js needs libuv
JavaScript in a browser runs alongside a rendering engine, an event dispatcher, and a handful of other threads managed by the browser itself. Node.js has none of that. It’s just a JavaScript runtime on top of V8, and the things a server does — reading files, accepting TCP connections, querying databases — all involve waiting on the operating system.
Different operating systems expose different asynchronous I/O primitives: epoll on Linux, kqueue on macOS, IOCP on Windows. libuv is the C library that wraps all of them behind a consistent API. When Node.js calls fs.readFile() or net.createServer(), it hands the work to libuv. libuv delegates to the OS, and when the OS finishes, libuv queues the completion callback for the JavaScript thread to run.
The JavaScript thread never blocks on I/O itself — it picks up callbacks from libuv’s queue and runs them one at a time. The event loop is the mechanism that keeps this pickup process organised. It has six named phases that always run in the same order.
The six libuv phases
Each phase has its own queue of callbacks. The loop moves through all six phases, drains what it can, then starts over from the top.
1. timers — Executes callbacks scheduled by setTimeout() and setInterval() whose delay threshold has passed. The threshold is a minimum, not a target. If a later phase runs long, timer callbacks fire late.
2. pending callbacks — Runs I/O error callbacks that were deferred from the previous iteration. The most common case: TCP errors like ECONNREFUSED on some Unix systems, which are delayed one tick before being reported.
3. idle, prepare — Internal to libuv. Your code never runs here.
4. poll — Retrieves new I/O completion events from the kernel and runs their callbacks. This is where the loop spends most of its time on a running server, and it has special blocking behaviour described in the next section.
5. check — Runs callbacks registered with setImmediate(). This phase always executes immediately after poll.
6. close callbacks — Fires 'close' events for handles that were destroyed abruptly, for example socket.destroy().
Then it loops back to timers and starts again.

The six libuv phases rotate clockwise. The poll phase (highlighted) is where the loop pauses to wait for I/O when nothing else is queued.
The poll phase: where the loop actually waits
Most confusion about Node.js timer behaviour comes down to not understanding what the poll phase does when its own queue runs empty.
If the poll queue is empty and setImmediate() callbacks are pending, the loop moves immediately to the check phase to run them.
If the poll queue is empty and nothing is scheduled with setImmediate(), the loop blocks inside the poll phase and waits for the OS to deliver new I/O events. It sits there until either a new event arrives or the time remaining before the next scheduled timer runs out — whichever comes first.
This means a timer scheduled with setTimeout(fn, 100) doesn’t fire at exactly 100 ms. If the loop is sitting in the poll phase waiting for a slow I/O operation, it won’t check timers again until it exits poll. The threshold is a lower bound, not a deadline. The official Node.js documentation describes this precisely: “A timer specifies the threshold after which a provided callback may be executed rather than the exact time a person wants it to be executed.”
One more thing to note: starting with libuv 1.45.0 (shipped in Node.js 20), timers run after the poll phase in each iteration. In earlier Node.js versions they ran before polling. The phase order above reflects modern Node.js behaviour.
process.nextTick() and microtasks: the inter-phase queues
process.nextTick() doesn’t appear in the six-phase diagram at all. It has its own queue — the nextTick queue — which drains completely between every phase transition. Before the loop moves from timers to pending callbacks, it drains the nextTick queue. Before it moves from poll to check, it drains it again. Before any phase change, the entire nextTick queue runs first.
Promise callbacks (microtasks) follow immediately after: once the nextTick queue is empty, Node.js drains the microtask queue before proceeding to the next phase.
The complete priority order after synchronous code finishes in a given context:
process.nextTick()callbacks — all of them, including any queued during the drain- Microtasks (resolved promise
.then/.catchcallbacks) — all of them - Next event loop phase
A concrete example shows the ordering:
setImmediate(() => console.log('setImmediate'));
setTimeout(() => console.log('setTimeout'), 0);
Promise.resolve().then(() => console.log('promise'));
process.nextTick(() => console.log('nextTick'));
// Output (always):
// nextTick
// promise
// setTimeout ← or setImmediate, order varies here
// setImmediate
The nextTick and promise callbacks win every time. The loop phase callbacks — setTimeout and setImmediate — follow in the next iteration.
One important warning from the Node.js docs: because the nextTick queue drains entirely before the next phase runs, recursive process.nextTick() calls can starve the I/O. The poll phase never gets to run, incoming connections pile up, and the server becomes unresponsive. This is allowed by design — there are legitimate reasons to want callbacks that run before any I/O — but it’s a footgun if you do it accidentally.
Check this before moving on
- You know
process.nextTick()callbacks run before microtasks (resolved promises) - You understand that the poll phase can block and wait — it’s not just a pass-through
- You know that
setTimeout(fn, 0)runs in the timers phase, which comes after poll has run
Why setImmediate() reliably wins inside I/O callbacks
Now the opening puzzle makes sense.
Outside an I/O callback, setTimeout(fn, 0) and setImmediate() race because the loop can be at different points when they’re registered. If the loop hasn’t started the timers phase yet and the 0 ms threshold has already passed, setTimeout fires first. If the timers phase already ran and the loop is mid-iteration, setImmediate goes first. The outcome depends on OS-level timer resolution and process startup time — both outside your control.
Inside an I/O callback, the situation is different. When a file-read callback fires, the loop is already in the poll phase. Registering a setImmediate() inside that callback queues it for the check phase. After the callback finishes (and after any nextTick / microtask queues drain), the loop moves directly to check. It doesn’t wrap back to timers first.
So setImmediate() runs before the next timers phase — guaranteed, not by accident. The Node.js docs state this clearly: within an I/O cycle, setImmediate() always executes before any timers, regardless of how many timers are present.
The practical takeaway: if you need to defer work until after the current I/O cycle completes, setImmediate() gives you a reliable slot. setTimeout(fn, 0) gives you “some time after the current iteration ends, probably.”
What blocks the loop — and what doesn’t
The event loop’s single JS thread means a long-running synchronous operation blocks everything. No I/O callbacks, no timers, no new connections, no nothing — for the full duration of the blocking work.
The things that block:
- Tight CPU loops (heavy computation, large JSON.parse, sorting enormous arrays)
- Synchronous file operations (
fs.readFileSyncon a large file) - Recursive synchronous algorithms that don’t yield
The things that don’t:
await-ing a promise (the async function suspends, the loop continues)- Calling
fs.readFile()(libuv handles the actual read off-thread) - A series of normal I/O callbacks (each runs to completion, but they’re short)
| Situation | Effect on the loop | What to do instead |
|---|---|---|
| Long synchronous computation | Loop blocked for its full duration | Break into chunks with setImmediate() to yield between iterations |
Recursive process.nextTick() |
Poll phase never runs; I/O starved | Use setImmediate() for recursive async patterns |
| Slow I/O callback (DB query, file parse) | Poll phase extended; next timers fire late | Move heavy work off the main thread with a worker |
setTimeout(fn, 0) from inside I/O |
Runs on the next timers phase, after check | Use setImmediate() if you need the post-poll slot specifically |
The mental model that holds across all of this: the event loop is a single rotating mechanism with six fixed stops. Every stop gets its turn. Every callback at that stop runs to completion before the loop moves on. Keep callbacks short, don’t starve the poll phase with nextTick recursion, and the loop stays responsive. Push anything CPU-heavy to a worker thread.
If you want to go one layer deeper — what JavaScript execution contexts and the call stack are doing while all this phase machinery runs — those posts cover the lower half of the picture.