Node.js Event Loop Differences
The Code That Works in Chrome but Breaks in Node
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
Run this in Node.js 10 times. Sometimes you get timeout, immediate. Sometimes immediate, timeout. The order is genuinely non-deterministic. Weird, right?
But wrap it in an I/O callback:
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
});
Now it's always immediate, timeout. Every single time.
If you understand the Node.js event loop phases, this behavior is obvious. If you're using the browser mental model? Completely incomprehensible.
The Mental Model
The browser event loop is a single loop with a priority slot (microtasks). The Node.js event loop is a six-lane roundabout. Each lane is a phase with its own queue. The loop circles through all six lanes in order, processing callbacks in each. Between EVERY phase, it drains the microtask queue. The lanes are always visited in the same order, so once you know which lane a callback enters, you know when it runs relative to callbacks in other lanes.
The Six Phases
Node.js uses libuv for its event loop, which defines six phases executed in strict order. Step through each phase to see what runs where:
Between every phase transition, Node drains two queues in this exact order: first all process.nextTick callbacks, then all other microtasks (Promise.then, queueMicrotask).
process.nextTick vs. queueMicrotask
Here's a subtlety that catches browser developers off guard. Node.js has two microtask-level APIs: process.nextTick and queueMicrotask. They look similar but they're not the same.
Promise.resolve().then(() => console.log('promise'));
queueMicrotask(() => console.log('queueMicrotask'));
process.nextTick(() => console.log('nextTick'));
Output in Node.js: nextTick, promise, queueMicrotask
process.nextTick runs before other microtasks. There's an internal "nextTick queue" that drains before the "microtask queue."
Phase transition:
1. Drain ALL process.nextTick callbacks (including newly added ones)
2. Drain ALL other microtasks (Promise.then, queueMicrotask)
3. Move to next phase
process.nextTick has the same starvation problem as microtasks. If a nextTick callback schedules another nextTick, the queue never drains, and the event loop never advances. Node.js has no safety limit for this. In Node.js 11+, there was a behavioral change: microtasks now drain between each phase (like the browser), but nextTick still has priority over Promise microtasks.
When to Use Which
| Use Case | API | Why |
|---|---|---|
| Need to run before any I/O | process.nextTick | Runs before all other microtasks and any I/O |
| Promise-compatible timing | queueMicrotask | Same queue as Promise.then — predictable |
| Cross-platform code | queueMicrotask | Works in browser and Node. nextTick is Node-only |
| API consistency (ensure async) | process.nextTick | Classic Node pattern for making sync APIs async |
The Node.js docs now recommend queueMicrotask over process.nextTick for most cases. nextTick is kept for backward compatibility and specific low-level use cases.
The setTimeout vs. setImmediate Race
Now let's solve the opening puzzle:
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
Why is this non-deterministic at the top level?
When Node.js starts, it takes time to set up the event loop. By the time it reaches the timers phase for the first iteration:
- If the setup took < 1ms, the timer hasn't expired yet. The timers phase has nothing. The loop continues to poll, then check (setImmediate). Output:
immediate, timeout. - If the setup took >= 1ms, the timer has expired. The timers phase fires the callback. Output:
timeout, immediate.
This is a race between Node.js initialization time and the system clock resolution. It's genuinely non-deterministic. And yes, that means the same code can produce different output on different machines -- or even on the same machine at different times.
Inside an I/O callback, there's no race. The callback runs in the poll phase. After poll comes check (setImmediate), then eventually timers (setTimeout). So setImmediate always wins.
Production Scenario: Server-Side Rendering Bottleneck
This is where understanding Node's event loop becomes a production survival skill. In a Node.js SSR server, you're rendering React components to HTML. Each render is CPU-intensive. With many concurrent requests, the event loop gets blocked:
// Bad: blocks the event loop during render
app.get('/page', (req, res) => {
const html = renderToString(<App />); // synchronous, 50ms
res.send(html);
});
50ms per render means the event loop is stuck in a single task for 50ms. Other requests, health checks, and WebSocket pings all wait.
// Better: yield between heavy operations
app.get('/page', async (req, res) => {
// Use React 18 streaming to break rendering into chunks
const stream = renderToPipeableStream(<App />, {
onShellReady() {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/html');
stream.pipe(res);
}
});
});
React's streaming renderer yields to the event loop between chunks, allowing other I/O to be processed. This is the Node.js equivalent of chunking work with setTimeout in the browser.
For non-React CPU-intensive work, use setImmediate to yield between chunks:
function processLargeDataset(items, callback) {
let index = 0;
const CHUNK = 100;
function processChunk() {
const end = Math.min(index + CHUNK, items.length);
while (index < end) {
transform(items[index]);
index++;
}
if (index < items.length) {
// setImmediate yields to I/O between chunks
setImmediate(processChunk);
} else {
callback(items);
}
}
processChunk();
}
setImmediate is preferred over setTimeout(fn, 0) for yielding in Node.js because it runs in the check phase of the current iteration, while setTimeout waits for the timers phase of the next iteration. It's slightly faster and doesn't have the 1ms minimum delay that setTimeout adds.
The libuv thread pool and its limits
Not all Node.js async operations are truly asynchronous at the OS level. File system operations, DNS lookups, and some crypto operations use libuv's thread pool (default size: 4 threads). If all 4 threads are busy with file reads, a 5th file read must wait. This is a common production bottleneck. Increase the pool size with UV_THREADPOOL_SIZE=16 (max 1024). Monitor with process._getActiveHandles() and process._getActiveRequests().
Common Mistakes
| What developers do | What they should do |
|---|---|
| Assuming the Node.js event loop works like the browser event loop The browser model has no concept of phases — it picks from task queues by priority. Node's phase ordering determines callback execution order. | Node.js has a phase-based event loop with 6 distinct phases. The browser has a simpler model with task queues and a render step. |
| Using process.nextTick for yielding to I/O process.nextTick drains before the event loop advances to the next phase. Using it for yielding is like using microtasks in the browser — it starves everything else. | Use setImmediate to yield. process.nextTick runs before I/O, so it doesn't actually yield. |
| Expecting setTimeout(fn, 0) and setImmediate(fn) to have consistent ordering at the top level At the top level, it depends on whether the system clock has ticked past the timer threshold by the time the timers phase runs. Inside I/O, phase ordering is deterministic. | The order is non-deterministic at the top level. Inside I/O callbacks, setImmediate always runs first. |
| Using process.nextTick in new code without a specific reason nextTick has priority over all other microtasks, which can cause starvation. queueMicrotask runs at the same level as Promise.then, which is more predictable. | Prefer queueMicrotask for new code. It's cross-platform and has predictable Promise-level timing. |
Challenge: Node.js Output Order
Challenge: Phase Ordering
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => console.log('A'), 0);
setImmediate(() => console.log('B'));
process.nextTick(() => console.log('C'));
Promise.resolve().then(() => console.log('D'));
queueMicrotask(() => console.log('E'));
console.log('F');
});
Show Answer
Output: F, C, D, E, B, A
Trace:
- The readFile callback runs in the poll phase.
console.log('F')runs synchronously. Output: F- The poll callback finishes. Before moving to the next phase, drain microtasks:
process.nextTickqueue first:C. Output: F, C- Then Promise/queueMicrotask queue:
D,E. Output: F, C, D, E
- Move to check phase:
setImmediatefires. Output: F, C, D, E, B - Close callbacks phase (nothing).
- Next iteration, timers phase:
setTimeoutfires. Output: F, C, D, E, B, A
Key insight: process.nextTick (C) runs before other microtasks (D, E). setImmediate (B) runs before setTimeout (A) because check phase comes before the next iteration's timers phase.
Key Rules
- 1Node.js event loop has 6 phases in fixed order: timers → pending → idle/prepare → poll → check → close. Callbacks run in the phase they belong to.
- 2Between EVERY phase transition, Node drains all process.nextTick callbacks, then all other microtasks (Promise.then, queueMicrotask).
- 3process.nextTick runs before Promise.then and queueMicrotask. It has its own priority queue. Prefer queueMicrotask for new code.
- 4Inside I/O callbacks (poll phase), setImmediate ALWAYS runs before setTimeout(fn, 0). At the top level, their order is non-deterministic.
- 5Use setImmediate (not process.nextTick) to yield to I/O in Node.js. nextTick runs before the event loop advances, so it doesn't actually yield.