Prefer reading? The full explanation
How Node Runs It
process.nextTick vs Promise · Node.js · CommonJS
Before this
How Promises Run — the microtask vs task distinction.
After this you can explain
Why the same Node file prints a different order as .cjs and as .mjs, and when process.nextTick is the wrong tool.
console.log('1: sync');
setTimeout(() => console.log('5: timeout'), 0);
Promise.resolve().then(() => console.log('4: promise'));
process.nextTick(() => console.log('3: nextTick'));
console.log('2: sync end'); Output: 1: sync → 2: sync end → 3: nextTick → 4: promise → 5: timeout
Why does nextTick run before the promise?
In a CommonJS file the output is 1 → 2 → 3 → 4 → 5. The process.nextTick callback is registered on line 7, after the promise on line 5, and still runs first.
Node maintains two separate queues that are drained between event loop phases: the nextTick queue first and completely, then the promise microtask queue. Because they are different queues, the order you registered callbacks across them is irrelevant — the same reasoning that makes promises beat timers in the browser, applied one level up.
The same file as ESM prints a different order
Save that snippet as t.cjs and t.mjs and run both. The CommonJS run prints 3: nextTick before 4: promise. The ESM run prints them the other way round.
The reason is that an ES module is evaluated as a module job, which itself runs inside a microtask. Top-level code in a .mjs file is therefore already executing within a microtask checkpoint, so when the body finishes, Node continues draining that checkpoint — running the pending promise — before it returns to the nextTick queue.
This is why a Node case should never be labelled just "Node.js". Any article that states a nextTick ordering without naming the module system is only half right, and this site labels every Node case explicitly.
Where these queues sit in the loop
Node's event loop runs through phases: timers, pending callbacks, poll, check, close callbacks. Between phase transitions it drains the nextTick queue and then the microtask queue.
That is why the setTimeout callback prints last in both runs. It belongs to the timers phase, and both intermediate queues are emptied before the loop advances to it. The CommonJS/ESM difference only reorders the two intermediate queues relative to each other, never the timer.
Why the Node docs call it legacy
Each queue is drained until empty, including callbacks added during the drain. A process.nextTick callback that schedules another one therefore pins the loop: promises never settle, timers never fire, and incoming I/O is never processed. The third experiment shows a two-step version of this.
Worth being precise about, because it is easy to draw the wrong conclusion: starvation is not unique to nextTick. A recursive queueMicrotask() or an endlessly growing promise chain starves the loop in exactly the same way — the microtask queue is also drained until empty. The rendering case on this site shows that version of the problem blocking paint.
So the reason Node recommends queueMicrotask() for ordinary deferral is portability and standard semantics, not safety. process.nextTick() is worth reaching for only when you specifically need its Node-only ordering — running before promise callbacks — such as emitting an error after the current call returns but before control goes back to the loop.
Reading about it is one thing. Watching it run is another.
↑ Run it step by step