JAVASCRIPT / PROMISES AND ASYNC PATTERNS
Microtasks against macrotasks in task order
Predict the exact order of synchronous code, promise callbacks and timer callbacks, and explain why microtasks can starve a timer.
What you will learn
- Classify any callback as a task or a microtask before predicting output order
- Apply the rule: one task per loop turn, then the whole microtask queue
- Recognise that an await resumption is a microtask, not a timer callback
- Reach for setTimeout, not a promise chain, when you need to yield to the browser
Understanding Microtasks against macrotasks in task order
The event loop keeps two kinds of pending work, and the difference is not priority in the vague sense but how many items get consumed per turn. Timer callbacks from setTimeout and setInterval, DOM event handlers, and message events go into task queues (what most articles call macrotasks). Promise reaction callbacks from .then, .catch and .finally, callbacks passed to queueMicrotask, the code after an await, and MutationObserver callbacks go into the microtask queue.
The rule that produces every ordering surprise on this topic is asymmetric consumption: the loop takes exactly one task, runs it to completion, and then empties the microtask queue entirely before considering another task. Emptying means emptying: if a microtask queues another microtask, that new one is consumed in the same drain rather than waiting for the next turn. A useful mental model is that microtasks are the tail of whatever task queued them, so promise callbacks feel like they belong to the code that scheduled them, while a timer callback is always a separate, later visit from the loop.
Two consequences follow. First, the delay argument to setTimeout is irrelevant to ordering against promises: even setTimeout(fn, 0) is a task, so every already-queued microtask runs first, and a self-replenishing microtask chain can keep the timer waiting forever. Second, the browser renders between tasks, not between microtasks, so awaiting in a tight loop over ten thousand items does not let the page paint or respond to clicks, while chunking the same work across setTimeout calls does.
console.log("sync start");
setTimeout(() => console.log("macrotask: setTimeout"), 0);
Promise.resolve().then(() => {
console.log("microtask: promise then");
queueMicrotask(() => console.log("microtask: queued from inside a microtask"));
});
console.log("sync end");Each turn of the event loop runs at most one task but then drains the entire microtask queue, so every pending promise callback runs before the next timer callback.
Worked examples
A microtask chain delays a ready timer
Microtasks queued from inside a microtask join the current drain, so the timer keeps waiting.
setTimeout(() => console.log("timer finally runs"), 0);
let n = 0;
function tick() {
n += 1;
console.log("microtask", n);
if (n < 3) queueMicrotask(tick);
}
queueMicrotask(tick);
console.log("end of script");Example explained
Line 1setTimeout registers its callback as a task immediately, but tasks are only picked up once the microtask queue is empty.
Line 2queueMicrotask(tick) inside tick appends to the queue that is currently draining, so the drain does not stop after microtask 1.
Line 3Only when n reaches 3 does tick stop re-queueing itself, which finally lets the loop move on to the timer.
Line 4Replace the n < 3 guard with an unconditional re-queue and the timer never runs at all.
await resumes as a microtask
The code after an await is scheduled on the microtask queue, so it runs before a zero-delay timer.
async function run() {
console.log("A");
await null;
console.log("C");
}
setTimeout(() => console.log("D"), 0);
run();
console.log("B");Example explained
Line 1"A" is plain synchronous code inside run, so it prints during the call itself.
Line 2await null wraps null in an already-resolved promise and returns control to the caller, which is why "B" prints before "C".
Line 3"C" is the resumption of run, queued as a microtask, so it is consumed at the checkpoint after the script.
Line 4"D" is a task and therefore comes last, even though its delay was 0.
The checkpoint happens after every task
Microtasks are drained between two timer callbacks, not batched until all timers have run.
setTimeout(() => {
console.log("timer 1");
Promise.resolve().then(() => console.log("micro from timer 1"));
}, 0);
setTimeout(() => {
console.log("timer 2");
Promise.resolve().then(() => console.log("micro from timer 2"));
}, 0);Example explained
Line 1The two timers are two separate tasks, even though they were scheduled with the same delay.
Line 2After timer 1 returns, the loop drains microtasks, so "micro from timer 1" prints before timer 2 starts.
Line 3This is why grouping work into one timer callback versus two changes when its promise callbacks observe the DOM.
Line 4Node.js before version 11 batched the drain until the end of the timers phase and printed both timer lines first.
Important notes
Macrotask is community jargon; the HTML specification says task and calls the drain point a microtask checkpoint. Ordering between two different task sources, such as a timer and a click, is not guaranteed the way promise ordering is.
Node.js adds a queue in front of the microtask queue: every process.nextTick callback runs before any promise callback, so mixing the two breaks predictions made from browser rules alone.
Common mistakes
Assuming setTimeout(fn, 0) beats a promise callback because zero means immediately; the timer callback is a task, so it lands after every pending microtask and your logs appear in the opposite order from the source.
Using an await or promise chain inside a long loop to keep the UI responsive; microtasks never reach a rendering opportunity, so the page stays frozen and the spinner you set never appears.
Expecting one whole .then chain to finish before another independent chain starts; each link is a separate microtask, so two chains interleave one step at a time and shared counters or logs come out mixed.
Try it yourself
Change, predict, then run
In a browser console, write a snippet with a top-level .then logging "micro" and a setTimeout(..., 0) whose callback logs "timer" and then queues a .then logging "after timer". Write the four-line order down on paper first, then run it and check whether "after timer" landed where you expected.
Open the JavaScript workspaceCheck your understanding
A promise callback queues another promise callback, which queues another, without ever stopping. What happens to a setTimeout(fn, 0) that was scheduled before that chain started?
- It never runs, because the microtask queue must be empty before the loop takes the next task
- It runs after the first microtask, because timer callbacks outrank promise callbacks
- It runs as soon as the 0 ms delay elapses, interrupting the microtask drain
- It runs after a few hundred microtasks, when the engine forces a checkpoint to stay responsive
Show answer
The microtask checkpoint runs until the queue is empty and counts microtasks added during the drain, so a self-replenishing chain never lets the loop reach the next task. Option 3 is tempting because the elapsed delay does matter, but it only makes the callback eligible to be queued as a task; queued is not the same as run, and nothing preempts a running microtask drain.