Microtasks vs Macrotasks
Node.js runs your asynchronous code through two distinct kinds of queues: microtasks and macrotasks. Understanding which queue a callback lands in — and when each queue drains — is the single most useful mental model for predicting the order in which your async code executes. Get it right and surprising output stops being surprising; get it wrong and you’ll chase phantom race conditions for hours.
The two queues
A macrotask (also called a “task”) is a unit of work scheduled by the event loop’s phases: timers (setTimeout, setInterval), I/O completion callbacks, setImmediate, and close handlers. The event loop processes exactly one macrotask per phase iteration, then yields.
A microtask is a smaller, higher-priority unit of work: resolved promise continuations (.then/.catch/.finally, await), queueMicrotask() callbacks, and — in Node specifically — process.nextTick() (which sits in its own even higher-priority queue). After every single macrotask completes, Node fully drains the microtask queues before moving on.
| Queue | What schedules it | Drain timing | Priority |
|---|---|---|---|
process.nextTick | process.nextTick(fn) | After current op, before promises | Highest |
| Microtask | Promises, await, queueMicrotask | After each macrotask, before next | High |
| Macrotask (timer) | setTimeout, setInterval | Timers phase | Normal |
| Macrotask (check) | setImmediate | Check phase | Normal |
| Macrotask (I/O) | fs, sockets, etc. | Poll phase | Normal |
The key rule: the microtask queue is drained to empty between every macrotask. A macrotask never runs while microtasks are still pending.
Basic ordering
Consider this script. Synchronous code runs first, then microtasks, then the first macrotask.
console.log('1: sync start');
setTimeout(() => console.log('2: setTimeout (macrotask)'), 0);
Promise.resolve().then(() => console.log('3: promise (microtask)'));
queueMicrotask(() => console.log('4: queueMicrotask (microtask)'));
console.log('5: sync end');
Output:
1: sync start
5: sync end
3: promise (microtask)
4: queueMicrotask (microtask)
2: setTimeout (macrotask)
The synchronous lines (1, 5) run top to bottom. When the call stack empties, Node drains the microtask queue (3, 4) — even though setTimeout was registered first. Only once microtasks are exhausted does the timer macrotask (2) fire.
process.nextTick beats promises
Node layers process.nextTick() ahead of the standard microtask queue. Its callbacks run before any promise continuation, after the current operation finishes.
Promise.resolve().then(() => console.log('promise'));
process.nextTick(() => console.log('nextTick'));
queueMicrotask(() => console.log('queueMicrotask'));
console.log('sync');
Output:
sync
nextTick
promise
queueMicrotask
Avoid recursive
process.nextTick()calls. Because the nextTick queue is fully drained before the loop can advance, an unbounded recursion will starve the event loop and block I/O entirely. PrefersetImmediatefor deferring work across loop iterations.
Microtasks drain completely between macrotasks
The most important consequence: a microtask can schedule more microtasks, and all of them run before the next macrotask. Macrotasks, however, are processed one per phase pass.
setTimeout(() => console.log('timeout A'), 0);
setTimeout(() => console.log('timeout B'), 0);
Promise.resolve().then(() => {
console.log('micro 1');
Promise.resolve().then(() => console.log('micro 2 (queued inside micro 1)'));
});
Output:
micro 1
micro 2 (queued inside micro 1)
timeout A
timeout B
Even though micro 2 is scheduled after both timers are already queued, it still runs before timeout A, because the entire microtask queue must empty before the loop returns to the timers phase.
await is just promise microtasks
async/await is syntactic sugar over promises, so each await suspends the function and resumes it as a microtask. This explains ordering inside async functions.
async function run() {
console.log('a: before await');
await null; // resumes as a microtask
console.log('c: after await');
}
console.log('start');
run();
console.log('b: after run() call');
Output:
start
a: before await
b: after run() call
c: after await
Everything up to the first await runs synchronously. The continuation after await (c) is scheduled as a microtask, so the synchronous b line runs first.
setTimeout vs setImmediate
Both are macrotasks but live in different event loop phases — timers vs check. Inside an I/O callback, setImmediate always fires before a setTimeout(0), because the check phase follows the poll phase in the same loop turn.
import { readFile } from 'node:fs/promises';
await readFile(import.meta.filename, 'utf8');
setTimeout(() => console.log('setTimeout'), 0);
setImmediate(() => console.log('setImmediate'));
Output:
setImmediate
setTimeout
(At the top level — outside an I/O callback — the order between these two is non-deterministic and depends on process startup timing.)
Best practices
- Reach for microtasks (promises,
queueMicrotask) when you need work to run as soon as the current stack clears, before any timer or I/O fires. - Use
setImmediateto yield back to the event loop and let pending I/O proceed — notsetTimeout(0), which is throttled to a minimum delay and lives in a different phase. - Treat
process.nextTickas a last-resort, high-priority hook. Never recurse on it, or you’ll starve I/O. - Never assume
setTimeout(fn, 0)runs “immediately” — every queued microtask runs first, and timers carry a clamped minimum delay. - Keep individual microtask callbacks short; long synchronous work inside one still blocks the loop because the queue drains fully before continuing.
- When debugging unexpected ordering, classify each callback as nextTick / microtask / macrotask first — the queue it lands in explains the order.