Free lesson · Node.js Deep Dive
Microtasks, Macrotasks, process.nextTick
Lesson 3.5 - Microtasks, Macrotasks, process.nextTick
In Lesson 3.4 I said something that I want to revisit and explain in detail: the process.nextTick queue and the microtask queue drain between every callback in the event loop. That sentence is the most important thing in this whole module. The exact ordering of those queues - and the rules for when they drain - are what separate Node engineers who can predict execution order from engineers who guess.
This lesson is the detailed treatment. We'll cover what each queue actually is, when each one drains, what gets pushed onto them, the priority ordering between them, and the specific bugs that come from misunderstanding them.
This is a lesson you'll want to read carefully and possibly twice. The concepts are small in number but easy to confuse.
Three Queues, Not Two
Node has three queues of work scheduled outside the six event-loop phases:
- The
process.nextTickqueue. Node-specific. Highest priority. - The microtask queue. V8-managed. Holds promise callbacks and
queueMicrotaskwork. Lower priority than nextTick. - The macrotask queue (kind of). This is actually the six event-loop phases - timers, I/O callbacks, setImmediate, etc. When you read browser-JS documentation, "macrotasks" is what they call this category. Node doesn't use that term internally because the six phases are different from each other, but conceptually a "macrotask" is anything that runs in one of those phases.
The three queues have different drain rules. Let me state them precisely.
Rule 1: The macrotask phases drain one callback at a time.
When the Timer phase runs, it processes timer callbacks. Between each timer callback, the loop pauses the Timer phase and goes to drain nextTick + microtasks. Then it returns to the Timer phase and processes the next timer. This is what made the "two timers" example in Lesson 3.4 surprising.
Rule 2: The nextTick queue drains completely, in FIFO order, before microtasks.
When the nextTick queue starts draining, it processes everything. If a nextTick callback enqueues another nextTick, that new one also runs. The queue can grow during drain, and the loop will keep draining until it's empty.
Rule 3: The microtask queue drains completely, in FIFO order, after nextTick.
Same rule as nextTick. If a microtask enqueues another microtask, the new one also runs. If a microtask enqueues a nextTick, the nextTick is added to the nextTick queue - but the microtask queue keeps draining first, and then the nextTick queue drains again before the loop continues.
Actually, that last claim deserves verification. Let me restate it carefully.
Precise rule: After a microtask runs that enqueued a nextTick, the microtask queue continues to drain until empty, then the nextTick queue drains. Within a single drain pass between callbacks, nextTick fires first, then microtasks fire, and any new nextTicks added during the microtask drain are processed at the start of the next drain pass (which happens right away, before the loop continues).
I'm going to verify this with code shortly. For now, hold this model.
The Priority Diagram
flowchart TD
Cb[Any callback finishes]
Cb --> Decide{Anything in<br/>nextTick queue?}
Decide -->|Yes| RunNT[Run all nextTicks<br/>until queue empty]
RunNT --> CheckMT{Anything in<br/>microtask queue?}
Decide -->|No| CheckMT
CheckMT -->|Yes| RunMT[Run all microtasks<br/>until queue empty]
RunMT --> Decide
CheckMT -->|No| Continue[Continue to next<br/>loop callback / phase]
style RunNT fill:#34d399,stroke:#10b981,color:#000
style RunMT fill:#fbbf24,stroke:#f59e0b,color:#000
A flowchart showing how the nextTick and microtask queues drain between callbacks. After any callback finishes, Node checks the nextTick queue. If it has entries, all of them run until the queue is empty. Then Node checks the microtask queue. If it has entries, all of them run until empty. If during the microtask drain new nextTick callbacks were enqueued, the loop goes back to drain the nextTick queue, then re-checks microtasks, and so on. Only when both queues are completely empty does the event loop continue to its next callback or phase.
The key shape: it's a loop between the two queues, not a one-pass drain. If a microtask schedules a nextTick, the nextTick runs before any further event-loop work - but the microtask queue still has to finish first.
Let me verify with code:
console.log('start');
process.nextTick(() => {
console.log('nextTick 1');
process.nextTick(() => console.log('nextTick 2'));
});
Promise.resolve().then(() => {
console.log('microtask 1');
process.nextTick(() => console.log('nextTick from microtask'));
Promise.resolve().then(() => console.log('microtask 2'));
});
console.log('end');
Predict the output. Then run.
The output is:
start
end
nextTick 1
nextTick 2
microtask 1
microtask 2
nextTick from microtask
Let me walk through it:
- Synchronous code runs first.
start,end. - Top-level finishes. Drain nextTick queue:
nextTick 1runs. It enqueues another nextTick.- The queue isn't empty yet - there's
nextTick 2. Run it. - Queue is now empty.
- Drain microtask queue:
microtask 1runs. It enqueuesnextTick from microtaskandmicrotask 2.- The queue isn't empty -
microtask 2is there. Run it. - Microtask queue is now empty.
- Re-check the nextTick queue (because microtasks may have added entries):
nextTick from microtaskis there. Run it.
- Re-check microtask queue: empty.
- Both queues empty. Now would continue to event loop, but there's nothing pending. Process exits.
Note specifically that microtask 2 ran before nextTick from microtask, even though the nextTick was added before microtask 2 was scheduled. That's because the microtask queue drains completely first, then the nextTick queue is checked. The two queues are not interleaved.
This is the actual model. It's subtle. Most engineers I've talked to get this wrong on the first try.
What Goes on the nextTick Queue
Only one thing: explicit calls to process.nextTick(fn). That's it. Nothing else in Node-land schedules a nextTick.
Why does this queue exist? Two reasons.
Reason 1: A way to defer work until after the current call stack unwinds. Suppose you're writing an EventEmitter, and you want to emit an event from inside a method, but you want the listeners to run after the method's caller has had a chance to register them. You can't emit synchronously - the listeners haven't been registered yet. setImmediate or setTimeout would work, but those are higher-overhead than necessary. process.nextTick(() => this.emit('foo')) defers the emission to "right after the current code finishes," with no event-loop iteration required.
Reason 2: Node-internal use. Several Node internals use nextTick to schedule callbacks. For example, error handling on EventEmitter sometimes uses nextTick to ensure error events are processed even if listeners weren't ready.
What process.nextTick is not for: scheduling normal async work. Use setImmediate for that, or just use a promise. process.nextTick is a sharper tool than setImmediate, and using it in normal application code is usually a mistake.
Warning: Calling
process.nextTickin a tight loop can starve the event loop. Because the nextTick queue drains completely before any other work proceeds, recursively scheduling nextTicks creates an infinite stream of work that the loop can never get past. The loop never reaches the I/O phases. Your HTTP server stops responding to requests. Pin this bug to the wall - I've seen it happen in production.
What Goes on the Microtask Queue
The microtask queue is V8-managed, not Node-managed. It holds:
.thenand.catchcallbacks of resolved/rejected Promises. When you writepromise.then(fn), V8 schedulesfnto run as a microtask whenpromiseresolves.queueMicrotask(fn)- the explicit JavaScript API to schedule a microtask. This is a Web API also available in Node.awaitcontinuations. When anawaitexpression resolves, the rest of the async function (the code after theawait) runs as a microtask.
Anything else that says "after the current synchronous code, but before the next event-loop phase" goes through microtasks.
The microtask queue is part of the JavaScript spec (sort of - it's specified in HTML, not ECMAScript, but V8 implements it for Node too). It exists for the same reason as nextTick: you sometimes need to defer work to "right after current execution" without iterating the loop. Most of the time, in your application code, when something "feels async but happens before the next setTimeout," it's a microtask.
A Subtle Difference Between nextTick and Microtasks
You might be wondering: if both queues drain after every callback, why are they separate? Why have two queues with effectively the same purpose?
The honest answer: history. process.nextTick predates promises in JavaScript. Node added nextTick early because it was useful. When Promises became standard later, V8 added the microtask queue. Node now has both, with nextTick draining first because that was its original behavior and changing it would break backward compatibility.
The practical difference today: prefer microtasks (via promises or queueMicrotask) for application code. Use process.nextTick only when you specifically need its higher priority - usually inside library code that wants to defer something to "right after the call stack."
In application code (the kind most of us write), microtasks via promises are the right tool. nextTick is rarely necessary.
"Microtask Starvation" - A Bug to Watch For
Just as nextTick can starve the loop, microtasks can too. If you have a recursive Promise chain that schedules a new microtask every time, you can prevent the event loop from ever advancing.
// DON'T DO THIS - it freezes the event loop
function loop() {
Promise.resolve().then(loop);
}
loop();
// Now your HTTP server has stopped responding.
// Your timers don't fire.
// process.nextTick callbacks still run (they share the starvation
// dynamic) but nothing else does.
This bug looks innocuous in code review. It's especially easy to write accidentally if you're polling for something with Promise.resolve().then(check) and check always reschedules itself. Use setImmediate instead for self-perpetuating async work - it lets the loop iterate between iterations.
What I Want You to Take From This Lesson
Four things.
One: nextTick and microtasks drain between every callback. This is the rule. Internalize it.
Two: nextTick has higher priority than microtasks. When both have entries, nextTick drains first, fully, then microtasks drain, fully, then any new nextTicks (scheduled during the microtask drain) are processed before continuing.
Three: Don't starve the loop. Recursive nextTick or recursive promise chains can prevent the event loop from making progress. If you have self-perpetuating async work, use setImmediate to let the loop iterate.
Four: For application code, prefer microtasks. Use Promises and queueMicrotask for deferring work. Save process.nextTick for library internals where you specifically need its priority.
Try It
Exercise: Warmup (5 minutes). Run the program from this lesson (
nextTick 1,microtask 1, etc.) and verify the output. If it matches what I predicted, your model is correct. If not, re-read the section.
Exercise: Core (15 minutes). Predict the output of:
process.nextTick(() => { console.log('A'); Promise.resolve().then(() => console.log('B')); }); process.nextTick(() => console.log('C')); Promise.resolve().then(() => { console.log('D'); process.nextTick(() => console.log('E')); });Write your prediction. Run. Compare. Expected: A, C, D, E, B. If yours differs, trace through carefully.
Exercise: Core (10 minutes). Reproduce the "nextTick starvation" bug. Write a program with a recursive
process.nextTick, plus asetTimeout(fn, 100). Run it. Notice that the timer never fires - you'll need to Ctrl+C to kill the program. This is the bug. Document it in your notes so you recognize it later.
Exercise: Stretch (45 minutes). Build a "race" between nextTick, microtasks, and immediate. Schedule 1,000,000 nextTicks, 1,000,000 microtasks, and 1,000,000 setImmediates. Measure how long each phase takes. The relative speeds will surprise you (nextTick is fastest, microtasks slightly slower, setImmediate noticeably slower). This is the cost difference these primitives impose.
What's Next
Lesson 3.6 zooms in on the famous interview question: setTimeout(fn, 0) vs setImmediate(fn) vs process.nextTick(fn). We've touched it; the next lesson covers it in detail with the deterministic and non-deterministic cases.