Skip to content

Node.js Event Loop Differences

intermediate14 min read

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

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).

Quiz
Inside an I/O callback, you call both setTimeout(fn, 0) and setImmediate(fn). Which runs first?

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 can starve like microtasks

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 CaseAPIWhy
Need to run before any I/Oprocess.nextTickRuns before all other microtasks and any I/O
Promise-compatible timingqueueMicrotaskSame queue as Promise.then — predictable
Cross-platform codequeueMicrotaskWorks in browser and Node. nextTick is Node-only
API consistency (ensure async)process.nextTickClassic 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.

Quiz
What does this Node.js code output? — Promise.resolve().then(() => console.log('promise')); queueMicrotask(() => console.log('queueMicrotask')); process.nextTick(() => console.log('nextTick'));

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.

Execution Trace
I/O
fs.readFile callback executes in the poll phase
Phase: poll
Register
setTimeout(fn, 0) → goes to timers queue. setImmediate(fn) → goes to check queue.
Both queued
Poll done
Poll phase complete. Check if setImmediate pending → yes.
Moving to check phase
Check
setImmediate callback executes
Output: immediate
Close
Close callbacks phase (nothing pending)
Phase: close
Timers
Next iteration: timers phase. setTimeout callback executes.
Output: immediate, timeout
Quiz
Why does setImmediate always run before setTimeout(fn, 0) inside an I/O callback?

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 doWhat 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:

  1. The readFile callback runs in the poll phase.
  2. console.log('F') runs synchronously. Output: F
  3. The poll callback finishes. Before moving to the next phase, drain microtasks:
    • process.nextTick queue first: C. Output: F, C
    • Then Promise/queueMicrotask queue: D, E. Output: F, C, D, E
  4. Move to check phase: setImmediate fires. Output: F, C, D, E, B
  5. Close callbacks phase (nothing).
  6. Next iteration, timers phase: setTimeout fires. 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

Key Rules
  1. 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.
  2. 2Between EVERY phase transition, Node drains all process.nextTick callbacks, then all other microtasks (Promise.then, queueMicrotask).
  3. 3process.nextTick runs before Promise.then and queueMicrotask. It has its own priority queue. Prefer queueMicrotask for new code.
  4. 4Inside I/O callbacks (poll phase), setImmediate ALWAYS runs before setTimeout(fn, 0). At the top level, their order is non-deterministic.
  5. 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.