Skip to content

process.nextTick vs Promises in Node.js

intermediate18 min read

The Output Nobody Gets Right on the First Try

setImmediate(() => console.log('A'));
setTimeout(() => console.log('B'), 0);
process.nextTick(() => console.log('C'));
Promise.resolve().then(() => console.log('D'));
queueMicrotask(() => console.log('E'));
console.log('F');

If you answered F, C, D, E, A, B or F, C, D, E, B, A — you're reasoning correctly about microtask priority but stumbling on the timer/immediate race. The correct answer is F, C, D, E followed by either B, A or A, B (non-deterministic at the top level). And that non-determinism? It's not a bug. Understanding why requires knowing the Node.js event loop phases — which is exactly what we're about to break down.

The Mental Model

Mental Model

Think of the Node.js event loop as a factory assembly line with six stations. The product (your callbacks) moves station to station in a fixed order. Between every station, a supervisor (the microtask drain) inspects the product — first checking the priority inbox (nextTick), then the regular inbox (Promises). No station starts until both inboxes are empty. The stations always run in the same order, so once you know which station handles a callback, you know exactly when it fires relative to everything else.

The Six Phases of the Node.js Event Loop

Here's where Node.js diverges from the browser event loop in a big way. Node.js uses libuv for its event loop, which defines six phases executed in strict order:

Between every phase transition, Node.js drains two queues in this exact order:

  1. All process.nextTick callbacks (including ones added during drain)
  2. All Promise/queueMicrotask callbacks (including ones added during drain)

This is the critical detail that most articles gloss over. It's not just "microtasks run between phases." There's a priority ordering within the microtask drain — and getting this wrong will lead you to the wrong answer every time.

Quiz
Between event loop phases, Node.js drains microtasks. In what order?

process.nextTick: Priority Microtask

Here's some history that explains a lot. process.nextTick predates Promises in Node.js. It was the original mechanism for deferring work until after the current operation completes but before any I/O runs. Despite the name, it runs before the next "tick" — at the tail of the current operation.

process.nextTick(() => console.log('nextTick 1'));
process.nextTick(() => console.log('nextTick 2'));
Promise.resolve().then(() => console.log('promise 1'));
Promise.resolve().then(() => console.log('promise 2'));

Output: nextTick 1, nextTick 2, promise 1, promise 2.

Both nextTick callbacks run before both Promise callbacks — every single time. The nextTick queue drains completely first, no exceptions.

Recursive nextTick: The Starvation Weapon

And now for the dangerous part. Because the nextTick queue drains before the Promise queue, and because the drain is recursive, a nextTick that schedules another nextTick can starve everything:

// DANGER: this starves Promises, I/O, timers — everything
function starve() {
  process.nextTick(starve);
}
starve();

// These never run:
setTimeout(() => console.log('timer'), 0);
Promise.resolve().then(() => console.log('promise'));
setImmediate(() => console.log('immediate'));

The event loop never advances past the nextTick drain. No I/O callbacks fire. No timers fire. Not even Promise callbacks fire — because nextTick sits above everything. Your server is alive but completely deaf to the outside world.

Common Trap

A recursive Promise.resolve().then() loop also starves timers and I/O, but at least it doesn't starve other Promises. A recursive process.nextTick starves everything — including Promises. This is the key difference: nextTick starvation is strictly worse than Promise starvation.

Quiz
A recursive process.nextTick loop is running. What still executes?

setImmediate vs setTimeout(fn, 0) vs process.nextTick

These three APIs look similar — they all say "run this later" — but they schedule work at very different points in the event loop. Mixing them up is one of the most common sources of subtle Node.js bugs.

APIRuns inPriorityUse case
process.nextTickBetween phases, before PromisesHighestEnsure async before any I/O
Promise.then / queueMicrotaskBetween phases, after nextTickHighStandard async deferral
setImmediateCheck phase (5)MediumYield to I/O between work chunks
setTimeout(fn, 0)Timers phase (1) of next iterationLowerDelay until next loop iteration

The setTimeout vs setImmediate Race

This is one of the most confusing behaviors in Node.js. At the top level (not inside an I/O callback), the order of setTimeout(fn, 0) and setImmediate(fn) is non-deterministic:

setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
// Could be either order

Why? It comes down to a timing race during startup. When Node.js starts, it takes a variable amount of time to initialize the loop. The timers phase runs first. If initialization took >= 1ms, the timer has already expired and fires. If < 1ms, the timer hasn't expired, the timers phase skips it, and the loop continues to poll then check (where setImmediate fires first). Literally depends on how fast your machine is that millisecond.

But here's the good news — inside an I/O callback, the order is always deterministic:

const fs = require('fs');
fs.readFile(__filename, () => {
  setTimeout(() => console.log('timeout'), 0);
  setImmediate(() => console.log('immediate'));
});
// Always: immediate, timeout

The I/O callback runs in the poll phase. After poll comes check (setImmediate), then close, then back to timers in the next iteration. So setImmediate always wins inside I/O — guaranteed by the phase ordering you just learned.

Quiz
Inside an fs.readFile callback, you call setTimeout(fn, 0) and setImmediate(fn). What's the order?

When process.nextTick Is Actually the Right Tool

So should you just avoid process.nextTick entirely? Not quite. Despite the starvation risk, it exists for genuinely good reasons. Here are the legitimate use cases:

1. Ensuring EventEmitter Listeners Are Attached

This is a classic chicken-and-egg problem, and nextTick solves it elegantly.

const EventEmitter = require('events');

class MyStream extends EventEmitter {
  constructor() {
    super();
    // Without nextTick, 'data' fires before user attaches listener
    process.nextTick(() => this.emit('data', 'ready'));
  }
}

const stream = new MyStream();
stream.on('data', (msg) => console.log(msg)); // 'ready'

See the problem? If emit were synchronous in the constructor, the event fires before the caller has a chance to attach a listener. process.nextTick defers the emit until after the constructor returns and the calling code has run — but before any I/O, ensuring the listener is attached in time.

2. Making Sync APIs Consistently Async

function readConfig(path, callback) {
  if (cache.has(path)) {
    // BAD: sometimes sync, sometimes async (Zalgo)
    // callback(null, cache.get(path));

    // GOOD: always async
    process.nextTick(callback, null, cache.get(path));
    return;
  }

  fs.readFile(path, (err, data) => {
    cache.set(path, data);
    callback(err, data);
  });
}

This avoids "releasing Zalgo" — the pattern where a function is sometimes synchronous and sometimes asynchronous, leading to the kind of subtle ordering bugs that make you question your career choices. nextTick guarantees the callback always runs asynchronously, with the highest possible priority.

3. Allowing the Call Stack to Unwind After Errors

function onError(err) {
  // Let the current stack finish before handling the error
  process.nextTick(() => {
    if (!handled) {
      throw err; // Becomes an uncaught exception after stack unwinds
    }
  });
}
Why not just use queueMicrotask for everything?

You could replace most process.nextTick calls with queueMicrotask and the code would still work. The difference is timing: nextTick runs before Promise callbacks, so if you need your callback to fire before any .then() handlers in the same drain cycle, nextTick is the only option. In practice, this matters in library code that must set up state before user Promise chains execute. For application code, queueMicrotask or just using Promises directly is almost always sufficient.

Quiz
Why does Node.js core use process.nextTick in EventEmitter constructors?

Why Promises Are Preferred in Modern Node.js

You might be wondering: if nextTick is so powerful, why does the Node.js documentation explicitly recommend queueMicrotask over it for new code? A few good reasons:

  1. Cross-platform: queueMicrotask and Promises work identically in browsers and Node. nextTick is Node-only.

  2. Lower starvation risk: A recursive Promise chain starves I/O, but at least other Promise callbacks can interleave. A recursive nextTick starves everything, including Promises.

  3. Predictable mental model: Promises have the same timing semantics everywhere. nextTick's priority-over-Promises behavior is a Node-specific quirk that trips up developers coming from browser code.

  4. Ecosystem alignment: Modern Node.js APIs (fs/promises, stream/promises, timers/promises) return Promises. Building on Promises keeps your async model consistent.

// Old Node.js pattern
function doWork(callback) {
  process.nextTick(() => callback(null, result));
}

// Modern Node.js pattern
async function doWork() {
  return result; // Naturally async via Promise
}

The exception: if you're writing low-level library code that must run before Promise .then() callbacks in the same cycle, nextTick is still the right tool. But be honest with yourself — that's a rare requirement, and if you're reaching for nextTick in application code, you probably don't need it.

Quiz
Why does the Node.js documentation recommend queueMicrotask over process.nextTick?

Production Pattern: Cooperative Scheduling with setImmediate

This is where theory meets real-world Node.js. When you're processing large datasets and want to keep your server responsive, setImmediate is the correct yielding tool — not nextTick, not Promises:

function processRecords(records, callback) {
  let index = 0;
  const CHUNK = 500;
  const results = [];

  function processChunk() {
    const end = Math.min(index + CHUNK, records.length);
    while (index < end) {
      results.push(transform(records[index]));
      index++;
    }

    if (index < records.length) {
      // setImmediate yields to I/O — other requests can be served
      setImmediate(processChunk);
    } else {
      callback(null, results);
    }
  }

  processChunk();
}

Why setImmediate and not process.nextTick? This is the part that trips people up. nextTick runs before I/O. If you use nextTick to yield, you're not actually yielding to I/O — you're just deferring within the microtask drain. Incoming HTTP requests, database responses, and file reads all wait until your nextTick chain finishes. You think you're being polite, but you're actually hogging the loop.

setImmediate runs in the check phase, which comes after the poll phase. This means the event loop visits the poll phase (where I/O callbacks execute) before running your next chunk. Other requests get served between your chunks — exactly what you want from a well-behaved server.

Quiz
You're building a Node.js API that processes 100k records per request. To keep the server responsive, you break the work into chunks. Which API yields to other I/O between chunks?

Common Mistakes

These are the mistakes we see over and over in Node.js codebases. If you've been writing Node for a while, you've probably made at least one of these.

What developers doWhat they should do
Using process.nextTick to yield to I/O in a processing loop
nextTick drains before the event loop advances to the next phase. Using it for yielding is like using a microtask to yield to rendering in the browser — it defeats the purpose.
Use setImmediate for yielding. nextTick runs before I/O and doesn't actually yield.
Treating process.nextTick and queueMicrotask as interchangeable
In Node.js, the nextTick queue drains before the microtask queue. Code that depends on running before Promise handlers needs nextTick. Code that should interleave with Promises should use queueMicrotask.
nextTick has strictly higher priority — it runs before Promise callbacks and queueMicrotask callbacks.
Expecting consistent setTimeout(fn, 0) vs setImmediate order at the top level
The race depends on whether the system clock has advanced past the 1ms timer threshold by the time Node.js reaches the timers phase during initialization. This varies per execution.
The order is non-deterministic at the top level. Only inside I/O callbacks is setImmediate guaranteed to run first.
Using process.nextTick in application code out of habit
The Node.js docs recommend queueMicrotask for most use cases. nextTick's priority semantics are a footgun in application code and make the code Node-specific.
Default to queueMicrotask or Promises. Reserve nextTick for library code that must run before Promise handlers.

Challenge: Node.js Phase Ordering

Ready to put it all together? This one is a beast — take your time and trace through each phase carefully.

Challenge: Full Priority Ordering

Try to solve it before peeking at the answer.

const fs = require('fs');

setImmediate(() => {
console.log('A');
process.nextTick(() => console.log('B'));
Promise.resolve().then(() => console.log('C'));
});

fs.readFile(__filename, () => {
console.log('D');
setImmediate(() => console.log('E'));
setTimeout(() => console.log('F'), 0);
process.nextTick(() => console.log('G'));
Promise.resolve().then(() => console.log('H'));
});

setTimeout(() => {
console.log('I');
process.nextTick(() => console.log('J'));
}, 0);

process.nextTick(() => console.log('K'));
Promise.resolve().then(() => console.log('L'));

Key Rules

Key Rules
  1. 1Node.js event loop has 6 phases in fixed order: timers → pending → idle/prepare → poll → check → close. Phase order determines callback execution order.
  2. 2Between every phase: drain ALL process.nextTick callbacks first, then ALL Promise/queueMicrotask callbacks. nextTick always has priority.
  3. 3process.nextTick starvation is worse than Promise starvation — it blocks everything, including Promise callbacks.
  4. 4setImmediate (check phase) always runs before setTimeout(fn, 0) (timers phase) when called from inside an I/O callback. At the top level, the order is non-deterministic.
  5. 5For yielding to I/O in processing loops, use setImmediate. process.nextTick and Promises run before I/O and don't actually yield.
  6. 6Prefer queueMicrotask over process.nextTick in application code. Reserve nextTick for library code that must execute before Promise handlers.