Microtasks vs Macrotasks
Node schedules async work in two queue families - microtasks (Promises, queueMicrotask, process.nextTick) and macrotasks (timers, setImmediate) - and the drain order explains subtle timing bugs.
Recipe
Ordering cheat sheet (simplified):
1. Run call stack to empty
2. Drain ALL process.nextTick callbacks
3. Drain ALL microtasks (Promises, queueMicrotask)
4. Run ONE macrotask (timer, I/O, setImmediate, ...)
5. Repeat from step 2
process.nextTick(() => console.log('nextTick'));
Promise.resolve().then(() => console.log('promise'));
setTimeout(() => console.log('timer'), 0);
// nextTick → promise → timerWhen to reach for this:
- Interview-style ordering puzzles in production code
- Debugging "double execution" after
await - Choosing between
nextTick,queueMicrotask, andsetImmediate - Preventing I/O starvation from recursive deferral
Working Example
import { setTimeout, setImmediate } from 'node:timers';
async function chain(): Promise<void> {
console.log('A');
await Promise.resolve();
console.log('B');
}
chain();
Promise.resolve().then(() => console.log('C'));
process.nextTick(() => console.log('D'));
setTimeout(() => console.log('E'), 0);
console.log('F');
// A, F, D, C, B, E (B may interleave with C depending on await timing)// Dangerous: recursive nextTick starves I/O
function spin(count: number): void {
if (count <= 0) return;
process.nextTick(() => spin(count - 1));
}
// spin(1_000_000); // Never do this in productionWhat this demonstrates:
- Sync code (
A,F) runs before any queued async work nextTickprecedes Promise microtasksawaitsplits async functions into microtask continuations- Recursive
nextTickprevents macrotasks (including I/O) from running
Deep Dive
How It Works
- Macrotasks map to libuv phases: timers, I/O callbacks,
setImmediate, etc. - Microtasks are an internal queue processed after each macrotask (and after sync code).
process.nextTickis technically separate from the Promise microtask queue but runs before it.queueMicrotaskschedules spec-compliant microtasks - prefer it overnextTickfor generic deferral.
Queue Comparison
| API | Queue type | Runs before |
|---|---|---|
process.nextTick | nextTick | Promises, timers |
Promise.then / await | microtask | Next macrotask |
queueMicrotask | microtask | Next macrotask |
setTimeout / setInterval | macrotask (timers) | Next loop tick |
setImmediate | macrotask (check) | Next iteration after poll |
TypeScript Notes
// Safe deferral - does not preempt Promise queue like nextTick
function defer(fn: () => void): void {
queueMicrotask(fn);
}
// Run after current I/O batch, not before other microtasks
import { setImmediate } from 'node:timers';
function deferMacrotask(fn: () => void): void {
setImmediate(fn);
}Gotchas
- Recursive
process.nextTick- starves timers and I/O indefinitely. Fix: usesetImmediatefor batching or a worker. - Assuming
awaitmakes code "parallel" - it only yields microtasks on the same thread. Fix: use workers for CPU parallelism. - Mixing
nextTickwith Promise chains for ordering hacks - fragile across Node versions. Fix: restructure with explicit state machines or queues. - Unhandled rejections in microtasks - crash the process if not caught before the next macrotask. Fix: always
try/catcharoundawaitin event handlers. - Logging microtask order in production - order bugs are dev-time issues; prod needs metrics. Fix: fix architecture, do not rely on log ordering.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
queueMicrotask | Spec-compliant deferral after current stack | You need check-phase timing (setImmediate) |
setImmediate | Batch work after I/O poll | You need before-next-macrotask Promise ordering |
| Job queue (BullMQ) | Cross-process async work | In-process ordering experiments |
async mutex libraries | Serialize access to shared state | Simple one-off deferral |
FAQs
Are Promises microtasks or macrotasks?
Microtasks. .then, .catch, .finally, and await continuations all use the microtask queue.
Why does nextTick exist if we have queueMicrotask?
Historical API predating microtasks in Node. nextTick runs before Promises - sometimes used in streams internals; avoid in app code.
How many microtasks run per tick?
All of them - the queue drains completely before the next macrotask starts.
Does setImmediate run before or after Promises?
After - setImmediate is a macrotask. All pending microtasks drain first.
What about async function return values?
An async function always returns a Promise. Code after await schedules as microtasks.
Can microtasks cause stack overflow?
Infinite microtask chains can exhaust memory or starve I/O - similar risk to recursive nextTick.
How do browsers differ?
Browsers lack process.nextTick and setImmediate. setTimeout(0) vs Promise ordering rules are similar.
Does queueMicrotask work in callbacks?
Yes - it schedules on the microtask queue for the current loop iteration, after the current call stack clears.
How do I test ordering?
Write explicit ordering tests with node:test and log arrays - but prefer eliminating order dependencies in app code.
What runs after await fetch?
Microtask continuations after the Promise settles, then further macrotasks for socket I/O underneath.
Is MutationObserver relevant in Node?
No - that is a browser API. Node microtasks come from Promises and queueMicrotask.
When should libraries use nextTick?
Rarely - streams legacy internals used it for compatibility. Application code should use queueMicrotask or setImmediate.
Related
- libuv Phases - where macrotasks come from
- async/await in Node - practical async patterns
- Event Loop Basics - first ordering examples
- Detecting Event-Loop Blockage - starvation symptoms
Stack versions: This page was written for Node.js 24.18.0 (Active LTS), npm 10+, TypeScript 5.6+, Express 5, Fastify 5, and NestJS 11.