Timers and Poll Phase
Two of the most important event loop phases are timers and poll. Understanding how they work together is key to writing predictable async code.
The Timers Phase
How Timers Work
When you call setTimeout() or setInterval(), Node doesn't create a new timer at the OS level for each one. Instead, it:
- Records the callback and threshold time
- Maintains a sorted list of pending timers
- Calculates when to check for due timers in poll phase
// Timer threshold = minimum wait time, not exact execution time
setTimeout(() => {
console.log('This runs AFTER at least 100ms');
}, 100);
Timer Execution Order
Timers with the same delay fire in order of registration:
setTimeout(() => console.log('A'), 100);
setTimeout(() => console.log('B'), 100);
setTimeout(() => console.log('C'), 100);
// Output: A, B, C (always in this order)
Timer Coalescing
Node coalesces timers for efficiency:
// All due timers fire in the same timers phase pass
setTimeout(() => console.log('1'), 50);
setTimeout(() => console.log('2'), 100);
setTimeout(() => console.log('3'), 100);
setTimeout(() => console.log('4'), 150);
// At 100ms: timers phase runs callbacks for 50ms and 100ms timers
The Timer Gotcha
Timer thresholds are minimums, not guarantees:
const start = Date.now();
setTimeout(() => {
console.log(`Actual delay: ${Date.now() - start}ms`);
}, 100);
// Heavy sync work blocks the event loop
for (let i = 0; i < 1e9; i++) {}
// Output: "Actual delay: 500ms" (or more)
The Poll Phase
This is where Node spends most of its time, waiting for I/O events.
Poll Phase Responsibilities
- Calculate timeout: How long to wait for I/O
- Process events: Execute callbacks for completed I/O
Poll Queue Not Empty
When there are callbacks to process:
const fs = require('fs');
// When file is read, callback goes to poll queue
fs.readFile('data.txt', (err, data) => {
console.log('File read complete');
});
// Poll phase will:
// 1. Find callback in queue
// 2. Execute it synchronously
// 3. Continue to next callback (or phase)
Poll Queue Empty
When no callbacks are waiting:
// If setImmediate is scheduled:
// → Move to check phase
// If timers are due:
// → Wrap back to timers phase
// Otherwise:
// → Wait (block) for new I/O events
Poll Timeout Calculation
// Poll will wait based on:
// 1. Nearest timer threshold
// 2. Whether setImmediate is scheduled
// 3. Whether close callbacks are pending
setTimeout(() => {}, 1000);
// Poll will wait at most 1000ms for I/O
setImmediate(() => {});
setTimeout(() => {}, 1000);
// Poll won't wait at all (setImmediate is scheduled)
Timers + Poll Interaction
The interplay between timers and poll creates interesting behavior:
const fs = require('fs');
setTimeout(() => {
console.log('timeout');
}, 0);
fs.readFile(__filename, () => {
console.log('file read');
});
// Possible outputs:
// 1. "timeout", "file read" (timer wins)
// 2. "file read", "timeout" (file wins)
// Depends on file system speed!
Making It Deterministic
Inside an I/O callback, the order is predictable:
const fs = require('fs');
fs.readFile(__filename, () => {
// We're in poll phase callback
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
});
// Output: "immediate", "timeout" (ALWAYS)
// setImmediate fires in check phase (next)
// setTimeout fires in timers phase (next iteration)
libuv 1.45.0 Change (Node.js 20+)
Starting with libuv 1.45.0:
- Timers run only after the poll phase
- Previously, they ran before AND after poll
// In Node.js 20+:
// 1. Poll phase (wait for I/O)
// 2. Timer phase (check due timers)
// Previously:
// 1. Timer phase (check due timers)
// 2. Poll phase (wait for I/O)
// 3. Timer phase again
Practical Examples
Server with Timeout
const http = require('http');
const server = http.createServer((req, res) => {
// Request callback runs in poll phase
setTimeout(() => {
// If client is slow, timeout handles it
res.end('Response');
}, 5000);
});
server.listen(3000);
Batching Database Writes
const writes = [];
let flushScheduled = false;
function scheduleFlush() {
if (flushScheduled) return;
flushScheduled = true;
// Use setImmediate to batch writes
// Runs after current I/O callbacks complete
setImmediate(() => {
flushScheduled = false;
db.batchWrite(writes.splice(0));
});
}
function write(data) {
writes.push(data);
scheduleFlush();
}
Key Takeaways
- Timers phase runs callbacks for expired timers
- Poll phase waits for I/O and processes I/O callbacks
- Timer delays are minimums, not guarantees
- Inside I/O callbacks, setImmediate always fires before setTimeout
- Don't block the event loop - it delays timer execution