process.nextTick() & queueMicrotask()
Node.js gives you two ways to defer a function so it runs after the current operation finishes but before the event loop moves on: process.nextTick() and queueMicrotask(). Both feel similar to a setTimeout(fn, 0), yet they fire far earlier — and the subtle differences in their ordering trip up even experienced developers. Understanding exactly when each callback runs is essential for writing predictable asynchronous APIs and for avoiding accidental event-loop starvation.
Where these callbacks fit
The event loop processes work in phases (timers, I/O, setImmediate, etc.). Between any two callbacks, Node.js drains two special queues before continuing:
- The nextTick queue — populated by
process.nextTick(). - The microtask queue — populated by
queueMicrotask()and resolved Promises (Promise.resolve().then(...)).
The critical rule: the entire nextTick queue is emptied first, and only then the microtask queue. Both are drained completely after the currently executing JavaScript finishes and before the event loop advances to the next phase. This means a nextTick callback always runs before a microtask scheduled at the same moment.
// schedule-order.js
console.log('1: sync start');
setTimeout(() => console.log('5: setTimeout'), 0);
Promise.resolve().then(() => console.log('4: promise microtask'));
queueMicrotask(() => console.log('3: queueMicrotask'));
process.nextTick(() => console.log('2: nextTick'));
console.log('1b: sync end');
Output:
1: sync start
1b: sync end
2: nextTick
3: queueMicrotask
4: promise microtask
5: setTimeout
Synchronous code runs first. Then the nextTick queue (2) drains entirely, then the microtask queue (3, 4) in FIFO order, and finally the event loop reaches the timer phase (5).
process.nextTick()
process.nextTick(callback) adds callback to the nextTick queue. It is a Node.js-specific API (not part of the JavaScript language) and has the highest priority of all deferred callbacks. Use it when you need to run something before any I/O or promise continuation, such as deferring an event emission so listeners have a chance to attach.
import { EventEmitter } from 'node:events';
class Resource extends EventEmitter {
constructor() {
super();
// Defer so the caller can attach a listener before 'ready' fires.
process.nextTick(() => this.emit('ready'));
}
}
const r = new Resource();
r.on('ready', () => console.log('resource ready'));
Output:
resource ready
Without the nextTick deferral, emit('ready') would run synchronously inside the constructor — before r.on('ready', ...) is even reached — and the listener would never fire.
queueMicrotask()
queueMicrotask(callback) is a cross-platform standard (also available in browsers and Deno). It enqueues a microtask using the same mechanism that powers resolved Promises, so queueMicrotask(fn) and Promise.resolve().then(fn) schedule at the same priority and run in the order they were queued. Prefer queueMicrotask over process.nextTick when you want portable, spec-compliant behavior and don’t need to jump ahead of promise continuations.
function loadConfig(useCache, cache, fetchFn) {
if (useCache) {
// Always async, even on the cache hit, for a consistent contract.
queueMicrotask(() => fetchFn(null, cache));
} else {
realLoad(fetchFn);
}
}
Deferring the cache-hit path with queueMicrotask keeps the callback always asynchronous, avoiding the notorious “release Zalgo” bug where an API is sometimes sync and sometimes async.
Comparing the three
| Mechanism | Queue | Standard? | Runs before |
|---|---|---|---|
process.nextTick() | nextTick queue | Node-only | microtasks, timers, I/O |
queueMicrotask() | microtask queue | Web standard | timers, I/O |
Promise.resolve().then() | microtask queue | Web standard | timers, I/O |
process.nextTick and queueMicrotask differ only in priority; queueMicrotask and Promise.then are functionally interchangeable for scheduling, though queueMicrotask is lighter (no promise allocation).
The starvation trap
Because the nextTick queue is fully drained before the event loop continues, a callback that recursively schedules another nextTick will never let the loop progress — blocking timers, I/O, and even setImmediate indefinitely.
let count = 0;
function spin() {
if (count++ > 5) return; // guard prevents infinite starvation
process.nextTick(spin);
}
spin();
setTimeout(() => console.log('timer finally ran'), 0);
Remove the guard and the setTimeout would never fire — the process appears hung while pegging the CPU. The same hazard applies to recursive queueMicrotask/promise chains, though nextTick makes it worse since it runs first.
Warning: Never recursively schedule
process.nextTick()for long-running work. If you need to break up a CPU-bound task across the event loop, usesetImmediate()instead — it yields to the I/O and timer phases between iterations.
Tip: Reach for
queueMicrotask()by default. Saveprocess.nextTick()for the rare cases where you must run before promise continuations, such as cleaning up state before any.then()handler observes it.
Best practices
- Default to
queueMicrotask()for deferring work — it is standard, portable, and avoids unnecessary promise allocations. - Use
process.nextTick()only when you genuinely need the highest priority, e.g. deferring anEventEmitteremit so listeners can attach. - Never build recursive
nextTickloops for ongoing work; usesetImmediate()to yield back to the event loop. - Make your asynchronous APIs consistently async — wrap synchronous fast paths in
queueMicrotask()to avoid Zalgo-style bugs. - Remember the order: synchronous code → nextTick queue → microtask queue → next event-loop phase.
- Don’t perform heavy CPU work inside microtasks or nextTick callbacks; they delay all I/O and timers until the queue drains.