Microtasks & Macrotasks
Two queues, one loop
Section titled “Two queues, one loop”The event loop processes async callbacks from two distinct queues with different priorities:
Microtask queue — processed after every task, before the next macrotask. Sources:
- Promise
.then/.catch/.finallycallbacks queueMicrotask(fn)
Macrotask queue (also called the task queue) — grouped into phases (timers, I/O, check, …). Node runs a whole phase’s due callbacks per event loop iteration (for example, every timer that has expired) — not just one task. Sources:
setTimeoutcallbackssetIntervalcallbacks- I/O callbacks (network, file system)
The rule is simple: after every individual callback — not just once per phase — Node drains process.nextTick and then the entire microtask queue before running the next callback.
The drain order
Section titled “The drain order”flowchart TB Run["Run one callback (or the initial script)"] --> Drain["Drain nextTick, then all microtasks (incl. new ones)"] Drain --> Next["Next callback in this phase, or next phase"] Drain -->|"new microtasks"| Drain
Node-specific: process.nextTick and setImmediate
Section titled “Node-specific: process.nextTick and setImmediate”Node.js adds two more scheduling APIs that require the Node runtime:
// These only work in Node.js — not in browsersprocess.nextTick(function() { console.log('nextTick fires before other microtasks');});
setImmediate(function() { console.log('setImmediate fires after I/O callbacks in the current loop iteration');});process.nextTick callbacks are drained before the microtask queue. setImmediate fires in the check phase of Node’s event loop. Because these require the Node runtime, they are not available in the in-browser runner below — run them locally or in StackBlitz.
Proof: the timers phase runs multiple callbacks, draining after each one
Section titled “Proof: the timers phase runs multiple callbacks, draining after each one”Two setTimeout(..., 0) calls both land in the same timers phase — but Node still drains process.nextTick and the microtask queue between them, before the second timer’s callback runs:
setTimeout(function() { console.log('timer 1'); process.nextTick(function() { console.log(' nextTick inside timer 1'); }); Promise.resolve().then(function() { console.log(' promise inside timer 1'); });}, 0);
setTimeout(function() { console.log('timer 2');}, 0);
// Output (verified on Node 24.13, x3):// timer 1// nextTick inside timer 1// promise inside timer 1// timer 2If “one macrotask per iteration” were true, timer 2 would already be queued for a later iteration and could not observe anything queued inside timer 1. Instead, both timers run in the same timers-phase pass, and the drain happens after every callback — not once per phase.
All four scheduling APIs together
Section titled “All four scheduling APIs together”process.nextTick, queueMicrotask/Promises, setImmediate, and setTimeout(fn, 0) are four different ways to say “later” — with four different priorities. At the very top of a script (the “main module”), this snippet schedules all four and prints which one actually goes first:
Output (verified on Node 24)
sync
Promise microtask
process.nextTick
setTimeout(0)
setImmediateNotice process.nextTick did not win here, even though process.nextTick queues are documented to drain before the microtask queue in general. At the very top of a module, before any real task has run, the exact interleaving of the module system’s own bookkeeping with these queues is not something Node’s documentation promises — and it is genuinely fragile: the same four lines wrapped in a require('node:module')-style CommonJS file instead of an ES module can print process.nextTick before Promise microtask here. Do not rely on the order you observe at the top level of a script.
What Node’s docs do guarantee is the order inside a real I/O callback — run the same four scheduling calls from inside an fs.readFile callback and the order is stable:
Output (verified on Node 24)
io callback: read 5 bytes
process.nextTick
Promise microtask
setImmediate
setTimeout(0)Inside the readFile callback, process.nextTick reliably wins, then the Promise microtask, then setImmediate — and setImmediate reliably beats setTimeout(0) specifically because an I/O callback runs in the poll phase, and libuv’s check phase (where setImmediate callbacks live) is the very next phase, before the loop comes back around to timers. This exact ordering — nextTick → microtasks → setImmediate → setTimeout — is what Node’s own documentation calls out as guaranteed specifically from inside an I/O cycle, which is precisely why the top-level result above should not be trusted as a general rule.
Runnable ordering demo
Section titled “Runnable ordering demo”This demo prints four lines. Predict the order before running:
- The synchronous
console.logruns immediately on the call stack. setTimeout(..., 0)queues a macrotask.Promise.resolve().then(...)queues a microtask (the Promise is already resolved, so the reaction is queued synchronously).queueMicrotask(...)queues another microtask — after the Promise reaction already queued above.
After the synchronous script finishes, all microtasks drain in order, then the macrotask fires.
The output is always in this order:
1 sync— call stack2 microtask (Promise)— first microtask queued3 microtask (queueMicrotask)— second microtask queued4 macrotask— runs only after the microtask queue is empty
Mental Model
Section titled “Mental Model”Think of the four scheduling APIs as four different lines at the same counter, checked in a strict order every single time the counter becomes free: process.nextTick’s line first, then the microtask (Promise/queueMicrotask) line — both checked until completely empty — and only once both are empty does the counter serve one item from the macrotask side, where setImmediate (check phase) and setTimeout (timers phase) sit in different rooms visited in a fixed order per loop pass. Code running “for the first time” at the top of a script is not yet standing at that counter in the usual way — the module system itself is still doing setup — which is exactly why ordering there is not something to depend on.
Trade-offs
Section titled “Trade-offs”Four scheduling primitives sound like unnecessary choice, but each answers a different question: process.nextTick for “before literally anything else, even other microtasks” (dangerous if overused — it can starve I/O entirely); queueMicrotask/Promises for “after this synchronous work, before any I/O or timer”; setImmediate for “after I/O callbacks in this same loop pass”; setTimeout(fn, 0) for “on the next timers phase, whenever that is.” Reaching for the wrong one either delays something that needed to run sooner, or — with nextTick especially — a recursive misuse can monopolize the JS thread and never let I/O callbacks run at all.
Common Misconceptions
Section titled “Common Misconceptions”- “The order I see when I run this once at the top of my script is guaranteed.” It is not — as shown above, this exact course found
process.nextTicklosing to a Promise microtask at the top level, which contradicts the general nextTick-first rule and is not something Node’s documentation promises at that point in a program’s life. - “setImmediate always runs before setTimeout(fn, 0), full stop.” Only guaranteed inside an I/O callback. At the top level or inside a plain timer callback, whichever of the two libuv actually reaches first in a given run can vary.
- “queueMicrotask and process.nextTick are just two names for the same queue.” They are two separate queues with a fixed priority between them — Node always fully drains the
nextTickqueue first, then the microtask queue, every single time either one is checked.
Reading AI-generated code
Section titled “Reading AI-generated code”AI-generated code sometimes reaches for Array.prototype.forEach with an async callback to “process each item and wait for it,” which quietly breaks in the same way an ignored macrotask ordering assumption does: the outer code moves on immediately, having queued work it will never wait for.
🐛 Spot the bug
async function importAll(records) {console.log('importing', records.length, 'records');records.forEach(async function (record) { await saveRecord(record); console.log('saved', record.id);});console.log('import finished');}Show the answer
importAll logs 'import finished' synchronously, right after forEach returns — which happens immediately, because forEach never looks at (or waits for) the Promise each async callback returns. None of the saveRecord calls are guaranteed to have completed, or even started their I/O, by the time 'import finished' prints, and any caller that awaits importAll(records) is misled into thinking every record was saved. The fix is a shape that actually waits: a for...of loop with await for sequential saves, or await Promise.all(records.map(saveRecord)) for concurrent ones — either way, importAll needs await in front of whichever one it uses before its own console.log('import finished').