The Event Loop
How Node.js processes callbacks across six phases to enable non-blocking async code
Overview
Key Points
- β’The event loop has six phases: timers β pending callbacks β idle/prepare β poll β check β close callbacks
- β’The poll phase is where Node waits for new I/O events; most of your I/O callbacks run here
- β’setImmediate callbacks run in the check phase, immediately after the poll phase
- β’process.nextTick and Promise microtasks run between every phase transition, not in a phase
- β’process.nextTick drains fully before Promise microtasks; both drain before the next phase starts
- β’Blocking the event loop in any phase (synchronous work) freezes all other callbacks
Examples
Event loop phase ordering β timers vs setImmediate vs nextTick vs Promises
setImmediate(() => console.log('setImmediate'));
setTimeout(() => console.log('setTimeout 0'), 0);
Promise.resolve().then(() => console.log('Promise microtask'));
process.nextTick(() => console.log('nextTick'));
console.log('sync');
// Output (always):
// sync
// nextTick β nextTick queue drains first
// Promise microtask β then Promise microtask queue
// setTimeout 0 β timers phase (or setImmediate first, OS-dependent outside I/O)
// setImmediate β check phaseThe nextTick and Promise order is deterministic. The setTimeout vs setImmediate order at the top level can vary β but inside an I/O callback setImmediate always wins.
Inside an I/O callback, setImmediate always fires before setTimeout
const fs = require('fs');
fs.readFile(__filename, () => {
// We are now inside the poll phase
setTimeout(() => console.log('setTimeout'), 0);
setImmediate(() => console.log('setImmediate'));
});
// Output (guaranteed):
// setImmediate
// setTimeout
// Because after poll finishes, the loop goes to the CHECK phase (setImmediate)
// before looping back to TIMERS (setTimeout).process.nextTick can starve the event loop if used recursively
// DANGER: this never lets the event loop advance to the next phase
function dangerousRecursion() {
process.nextTick(dangerousRecursion);
}
// dangerousRecursion(); // uncomment to freeze Node
// Safe pattern: use setImmediate for recursive async work
function safeRecursion(count) {
if (count <= 0) return;
setImmediate(() => safeRecursion(count - 1));
}
safeRecursion(1000); // works fine, each iteration yields to event loopprocess.nextTick starves because it runs before advancing to the next phase. setImmediate yields after the current phase.
Visualising the poll phase waiting for I/O
const net = require('net');
const server = net.createServer((socket) => {
socket.on('data', (data) => {
// This callback runs in the POLL phase when new data arrives
console.log('received:', data.toString());
socket.write('echo: ' + data);
});
});
server.listen(3000);
console.log('Server listening β event loop is polling for connections');
// When no data arrives, the loop blocks in the poll phase waiting.
// When data arrives, the OS wakes it up and the callback runs.Common Mistakes
- Relying on setTimeout(fn, 0) vs setImmediate ordering at the top level β their order is not guaranteed; use setImmediate when you need "after current I/O"
- Using process.nextTick recursively without a base case, which starves the event loop indefinitely
- Assuming Promise.resolve().then() fires before process.nextTick β it is actually the other way around
- Forgetting that synchronous code inside any callback (including async functions between awaits) still blocks the event loop for its full duration
Note
A practical rule: use process.nextTick to defer something until after the current function returns but before any I/O. Use setImmediate to defer something until after the current I/O events. Use setTimeout(fn, 0) only when you specifically need timer semantics.
Exercises
Write Node.js code in your local terminal. Use the editor below to follow along, then mark each exercise as done once it works.
Completed: 0 / 6 exercises
Knowledge Check
Question 1 of 4