The JavaScript event loop: microtasks, macrotasks, and process.nextTick
Every so often I write a piece of Node.js code, look at the output, and have to remind myself why it printed in that order. The usual culprit is the gap between “I scheduled this earlier” and “this runs earlier” — they’re not the same thing. The event loop has a strict priority system, and once you internalize it, the surprising orderings stop being surprising. This post is the version of that explanation I wish I’d had the first time.
The three queues
JavaScript runtimes split deferred work into a few different queues, each with a different priority.
Macrotasks (sometimes just called “tasks”) include setTimeout, setInterval, setImmediate (Node.js), and I/O callbacks. The event loop processes one macrotask per iteration.
Microtasks include Promise.then/catch/finally, async/await continuations, and queueMicrotask. After every macrotask — and after the initial synchronous script — the loop drains the entire microtask queue before moving on. Microtasks always run before the next macrotask.
process.nextTick is Node-only, and has higher priority than even microtasks. Node maintains a separate nextTick queue that’s drained before the microtask queue, on every transition between phases of the event loop.
So the priority order, from highest to lowest, is:
synchronous code > process.nextTick > microtasks > macrotasks
That last word — priority — is what trips people up. The order you write the calls doesn’t matter; the queue they land in does.
A worked example
Consider this script:
console.log('1. start of script');
setTimeout(() => {
console.log('5. setTimeout callback');
}, 0);
process.nextTick(() => {
console.log('3. process.nextTick callback');
});
Promise.resolve().then(() => {
console.log('4. promise .then callback');
});
console.log('2. end of script');
Even though setTimeout is registered first and process.nextTick is written third, the output is:
1. start of script
2. end of script
3. process.nextTick callback
4. promise .then callback
5. setTimeout callback
Walking through it: the two top-level console.log calls (1 and 2) run first because they’re on the current call stack. The setTimeout, nextTick, and Promise.then calls all register callbacks but don’t execute them — they put work on their respective queues and return immediately.
Once the synchronous script finishes, but before Node moves into any event-loop phase, it drains the nextTick queue. So callback 3 fires next.
After that, the microtask queue gets drained. The promise resolution callback (4) is in there, so it runs.
Finally the loop enters the timers phase and runs the setTimeout callback (5). One macrotask per iteration; the others would have to wait their turn.
If you swap the order in which you register them, the output is identical. That’s the whole point: registration order doesn’t matter — queue priority does.
Why process.nextTick is its own thing
It’s reasonable to ask why Node bothered with a queue above microtasks when promises were already there. The historical answer is that process.nextTick predates promises in Node, and was the original “run this right after the current operation finishes” primitive. It stuck around because it has a useful property: it lets a function defer work to immediately after the current stack unwinds, without yielding to I/O or timers in between.
The trade-off is that it’s too powerful. Because the nextTick queue is drained completely before any I/O happens, a recursive process.nextTick can starve the event loop entirely — no timers, no I/O, nothing else gets a turn. The Node docs explicitly warn about this, and it’s why most modern code reaches for queueMicrotask or Promise.resolve().then(...) unless nextTick’s extra-high priority is genuinely needed.
The mental model
Whenever I’m reading code and trying to predict the output order, I run through the same checklist. What’s synchronous? That runs first, top to bottom. What’s on the nextTick queue? That drains next, in Node. What’s on the microtask queue? That drains after nextTick, fully, before any macrotask. What’s on the macrotask queue? One per iteration, then back to the beginning.
The key insight is that microtasks and nextTick callbacks are drained exhaustively between macrotasks. A Promise.then that schedules another Promise.then will run that follow-up before the next setTimeout fires, even if the timer was registered first. The event loop does not move on until those queues are empty.
Once that hierarchy clicks, the surprising orderings disappear. The runtime isn’t being mysterious — it’s just enforcing a priority order that the calling code doesn’t get a vote in.