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:
- The nextTick queue (
process.nextTick). - The promise microtask queue (
.then,awaitcontinuations,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
// promiseThe 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
// 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.
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 -> CDeep microtask recursion can starve I/O
Demonstrates that infinitely chaining `.then()` starves I/O, and `setImmediate` is the correct fix.
// 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.
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