Skip to content

Microtasks & Macrotasks

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 / .finally callbacks
  • 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:

  • setTimeout callbacks
  • setInterval callbacks
  • 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.

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
After every callback, Node drains process.nextTick and the full microtask queue — including microtasks queued by microtasks — before moving on

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 browsers
process.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 2

If “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.

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:

Node.js
Output (verified on Node 24)
sync
Promise microtask
process.nextTick
setTimeout(0)
setImmediate

Notice 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:

Node.js
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.

This demo prints four lines. Predict the order before running:

  1. The synchronous console.log runs immediately on the call stack.
  2. setTimeout(..., 0) queues a macrotask.
  3. Promise.resolve().then(...) queues a microtask (the Promise is already resolved, so the reaction is queued synchronously).
  4. 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.

JavaScript

The output is always in this order:

  • 1 sync — call stack
  • 2 microtask (Promise) — first microtask queued
  • 3 microtask (queueMicrotask) — second microtask queued
  • 4 macrotask — runs only after the microtask queue is empty

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.

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.

  • “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.nextTick losing 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 nextTick queue first, then the microtask queue, every single time either one is checked.

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').

Given this code — what prints first: the Promise .then callback or the setTimeout callback?
What does queueMicrotask(fn) do?
If a microtask callback itself calls queueMicrotask, when does the new microtask run?
What does this print? (assume the file exists and this runs inside a real fs.readFile callback)
readFile('x', 'utf8', function (err, data) {
  setTimeout(function () { console.log('timeout'); }, 0);
  setImmediate(function () { console.log('immediate'); });
  process.nextTick(function () { console.log('nextTick'); });
});
An AI-written function does records.forEach(async function (r) { await saveRecord(r); }) followed immediately by console.log('done'). What is guaranteed about the timing of "done" relative to the saves?