Skip to content
Node.js nd async 4 min read

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.

QueueWhat schedules itDrain timingPriority
process.nextTickprocess.nextTick(fn)After current op, before promisesHighest
MicrotaskPromises, await, queueMicrotaskAfter each macrotask, before nextHigh
Macrotask (timer)setTimeout, setIntervalTimers phaseNormal
Macrotask (check)setImmediateCheck phaseNormal
Macrotask (I/O)fs, sockets, etc.Poll phaseNormal

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. Prefer setImmediate for 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 setImmediate to yield back to the event loop and let pending I/O proceed — not setTimeout(0), which is throttled to a minimum delay and lives in a different phase.
  • Treat process.nextTick as 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.
Last updated June 14, 2026
Was this helpful?