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:

  1. Records the callback and threshold time
  2. Maintains a sorted list of pending timers
  3. 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

  1. Calculate timeout: How long to wait for I/O
  2. 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:

// 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

  1. Timers phase runs callbacks for expired timers
  2. Poll phase waits for I/O and processes I/O callbacks
  3. Timer delays are minimums, not guarantees
  4. Inside I/O callbacks, setImmediate always fires before setTimeout
  5. Don't block the event loop - it delays timer execution