Microtasks vs Macrotasks

Why promises and nextTick always jump the queue, and how that can starve the loop.

The phase queues (timers, poll, check…) hold macrotasks. Between every macrotask — not just between phases — Node drains two microtask queues completely:

  1. The nextTick queue (process.nextTick).
  2. The promise microtask queue (.then, await continuations, queueMicrotask).

The nextTick queue is drained first, and both are drained to empty before the loop is allowed to advance. That is the whole ordering rule.

Promise.resolve().then(() => console.log('promise'));
process.nextTick(() => console.log('nextTick'));
console.log('sync');

// sync
// nextTick   <- nextTick queue drains before promise queue
// promise

The starvation trap

Because microtasks are drained to empty before any I/O runs, a microtask that schedules another microtask forever will starve the event loop — timers never fire, sockets never get read. This is a real production incident pattern, not a toy concern.

Example

Example · javascript
// Two ways to 'loop later'. One starves I/O, one does not.

// STARVES the loop: microtasks never let the poll phase run.
function greedy() {
  process.nextTick(greedy); // or Promise.resolve().then(greedy)
}

// COOPERATIVE: setImmediate is a macrotask, so between each call
// the loop can service timers and sockets.
function polite(remaining) {
  if (remaining === 0) return;
  // ...do a slice of work...
  setImmediate(() => polite(remaining - 1));
}

polite(1_000_000);
setTimeout(() => console.log('this DOES fire with polite(), never with greedy()'), 50);

// Takeaway: partition CPU-bound work across setImmediate ticks so the
// server stays responsive. Better still, move it to a Worker (see prod section).

When to use it

  • A caching layer wraps synchronous cache hits in `Promise.resolve()` so callers always get a Promise, keeping the API uniform regardless of whether data was cached.
  • A test framework uses `queueMicrotask` to flush pending assertions after each `await` so failures are reported at the right test boundary.
  • A scheduler library uses the microtask queue to batch multiple synchronous state updates and re-render only once after all changes are applied.

More examples

Promise microtasks vs nextTick

Shows that `process.nextTick` drains first (own queue), then Promise/queueMicrotask microtasks drain together.

Example · js
process.nextTick(() => console.log('A: nextTick'));
queueMicrotask(() => console.log('B: queueMicrotask'));
Promise.resolve().then(() => console.log('C: Promise.then'));
console.log('D: sync');
// D -> A -> B -> C

Deep microtask recursion can starve I/O

Demonstrates that infinitely chaining `.then()` starves I/O, and `setImmediate` is the correct fix.

Example · js
// WARNING: infinitely recursive microtasks block all I/O
function bad() {
  Promise.resolve().then(bad);
}
// bad(); // Don't call this -- starves the event loop

// Fix: use setImmediate to yield to the event loop
function good(n) {
  if (n <= 0) return;
  setImmediate(() => good(n - 1));
}
good(1000);

queueMicrotask for non-Promise callbacks

Uses `queueMicrotask` to defer a batch-complete callback until after the current synchronous loop finishes.

Example · js
const results = [];

function process(item) {
  results.push(item * 2);
  if (results.length === 3) {
    queueMicrotask(() => console.log('Batch done:', results));
  }
}

[1, 2, 3].forEach(process);
// Batch done fires after current sync code, before any I/O

Discussion

  • Be the first to comment on this lesson.