libuv Phases
The libuv event loop runs phases in a fixed order each tick - knowing the sequence explains timer drift, setImmediate behavior, and when I/O callbacks fire.
Recipe
Phase order (one loop iteration):
timers → pending → idle/prepare → poll → check → close callbacks
↑ microtasks drain between phases ↑
import { setTimeout, setImmediate } from 'node:timers';
setTimeout(() => console.log('timer'), 0);
setImmediate(() => console.log('immediate'));When to reach for this:
- Debugging
setImmediatevssetTimeout(0)ordering - Understanding why timers drift under CPU load
- Explaining when socket
datahandlers run relative to timers - Designing deferral that does not starve I/O
Working Example
import { readFile } from 'node:fs';
import { setTimeout, setImmediate } from 'node:timers';
readFile(__filename, () => {
console.log('1: I/O callback (poll phase)');
setTimeout(() => console.log('2: timer'), 0);
setImmediate(() => console.log('3: immediate (check phase)'));
});
setTimeout(() => console.log('4: outer timer'), 0);
setImmediate(() => console.log('5: outer immediate'));Typical output pattern:
- Outer timer and immediate order varies at top level (4/5 swap).
- Inside the
readFilecallback:1, then3(immediate), then2(timer).
What this demonstrates:
- I/O callbacks execute in the poll phase
setImmediatein an I/O callback runs in the check phase of the same iteration- Timers scheduled inside I/O wait for the next iteration's timers phase
Deep Dive
How It Works
- Timers phase runs callbacks whose threshold has expired (
setTimeout,setInterval). - Pending phase runs internal system operations (e.g., some TCP error callbacks).
- Poll phase fetches new I/O events and executes I/O-related callbacks; may block waiting for events.
- Check phase runs
setImmediatecallbacks. - Close phase runs
closeevent callbacks (e.g.,socket.on('close')).
Phase Reference
| Phase | API examples | Notes |
|---|---|---|
| timers | setTimeout, setInterval | Minimum delay, not exact under load |
| poll | fs.readFile, socket.on('data') | Can block if no other work scheduled |
| check | setImmediate | Runs after poll in same tick |
| close | server.close, handle cleanup | Last chance for resource teardown |
TypeScript Notes
import { setImmediate } from 'node:timers';
// Defer work until after I/O callbacks in this tick
function deferAfterIo(fn: () => void): void {
setImmediate(fn);
}Gotchas
- Assuming
setTimeout(fn, 100)fires exactly at 100ms - phases and load add drift. Fix: use monotonic clocks for deadlines, not timer count alone. - Starving poll with infinite
setImmediateloops - check phase never yields to I/O. Fix: usesetImmediateonce per item, batch work, or use workers. - Confusing microtasks with phases - Promise callbacks are not a libuv phase; they run between phases. Fix: read Microtasks vs Macrotasks.
- Blocking poll with sync work in I/O callbacks - delays timers and other sockets. Fix: keep I/O callbacks thin; defer heavy work to workers.
- Expecting top-level timer/immediate order - before I/O, either can win. Fix: do not rely on order until inside an I/O callback.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
queueMicrotask | Run before next macrotask, after current stack | You need to wait for I/O poll to finish |
setImmediate | Defer after current poll phase | Sub-millisecond timing precision required |
setTimeout | Time-based scheduling with tolerance | Exact scheduling under heavy CPU load |
| Worker thread message | CPU work off main thread | Simple deferral of a few lines |
FAQs
How many phases run per event loop tick?
One full cycle through timers, pending, idle/prepare, poll, check, and close - with microtasks drained between phases.
Where does setImmediate run?
In the check phase, immediately after the poll phase completes in that iteration.
Why do timers drift?
Timers fire only when the loop reaches the timers phase. Blocking sync work or saturated poll delays that phase.
What runs first after an HTTP request body arrives?
The socket data handler in poll phase, then microtasks from any Promises it creates, then check-phase immediates.
Is poll phase blocking?
It can wait for I/O with a computed timeout. If timers or immediates are due, it times out and continues.
How does process.nextTick relate to phases?
nextTick is not a libuv phase - it runs between phases and before Promise microtasks. Overuse starves I/O.
When does close phase matter?
When tearing down servers and sockets - close callbacks release handles. Important for graceful shutdown.
Can I skip phases?
No - libuv always walks the cycle. You influence what callbacks are queued for each phase.
How does this differ from browser event loop?
Similar microtask/macrotask ideas; Node adds setImmediate, process.nextTick, and different I/O integration via libuv.
What debug flag helps?
NODE_DEBUG=timer traces timer insertions and firings - useful locally, noisy in production.
Does fetch use poll phase?
Yes - network I/O completion callbacks schedule through libuv like node:http.
How do libuv phases affect Express middleware?
Each middleware await yields microtasks; response send triggers I/O in poll. Long sync middleware blocks all phases.
Related
- Microtasks vs Macrotasks - Promise and nextTick ordering
- Event Loop Basics - introductory examples
- Timers & Scheduling - drift and intervals
- Detecting Event-Loop Blockage - when phases stall
Stack versions: This page was written for Node.js 24.18.0 (Active LTS), npm 10+, TypeScript 5.6+, Express 5, Fastify 5, and NestJS 11.