The Event Loop: Microtasks vs Macrotasks
How JavaScript runs async code on a single thread, why promise callbacks beat setTimeout, and how to predict the output of any ordering puzzle.
JavaScript runs your code on one thread, but it never blocks while waiting for a timer, a network response or a click. The event loop is the scheduler that makes this work: it runs one task to completion, drains every queued microtask, gives the browser a chance to render, then picks the next macrotask. Once you can see those queues, every "what does this print?" puzzle becomes mechanical.
Why it matters
Almost every hard-to-reproduce frontend bug lives here: a state update that shows up one frame late, a spinner that never paints because a loop hogs the thread, a "flash" of stale UI, or a Node service whose latency spikes because one request starved the loop. Understanding the loop is also how frameworks work: React batching, Vue's nextTick, and every "defer this until after paint" trick are just choices about which queue to put work on.
The model: one stack, two kinds of queue
Three pieces are enough to reason about everything that follows.
- Call stack — where synchronous code runs. A function call pushes a frame; returning pops it. The loop never interrupts a running frame.
- Macrotask queue (the spec just says "task queue") — timers, I/O callbacks, UI events,
setImmediatein Node,MessageChannelmessages, script execution itself. One task runs per turn of the loop. - Microtask queue — promise reactions,
queueMicrotask,MutationObservercallbacks, and the continuation after everyawait. Drained completely after each task, before anything else happens.
The single most important rule: the microtask queue is drained to empty before the loop moves on. If a microtask queues another microtask, that one runs too, in the same checkpoint. Macrotasks never get that treatment. One runs, then the loop checks microtasks, then maybe renders, then takes the next task.
Worked example: predict the output
console.log('A');
setTimeout(() => console.log('B'), 0);
Promise.resolve().then(() => {
console.log('C');
Promise.resolve().then(() => console.log('D'));
});
queueMicrotask(() => console.log('E'));
console.log('F');Walk it the way the engine does.
- 1
The script itself is a macrotask. It runs top to bottom:
Aprints.setTimeouthandsBto the timer, which will enqueue a macrotask later. Thethencallback andEare queued as microtasks.Fprints. The stack is now empty. - 2
Microtask checkpoint. Queue is
[C-callback, E]. Run the first:Cprints and it queuesDat the back:[E, D]. RunE. RunD. Queue empty, checkpoint ends. - 3
The browser may render. Then the next macrotask: the timer callback.
Bprints.
Where async/await fits
await is syntax over promises, so it uses the microtask queue. Everything before the first await in an async function runs synchronously, as part of the current task. The line after the await is the continuation, and it is queued as a microtask once the awaited value settles.
async function run() {
console.log('1');
await null;
console.log('3');
}
run();
console.log('2');
// 1 2 3function run() {
console.log('1');
return Promise.resolve(null)
.then(() => {
console.log('3');
});
}
run();
console.log('2');
// 1 2 3One subtlety worth knowing for interviews: await somePromise takes exactly one microtask tick in modern engines (since V8 7.2 / Node 12). Older engines wrapped the value in extra promises and needed three ticks, which is why some blog posts from 2018 show different orderings. Do not memorize tick counts; reason about "continuation is a microtask" and you will be right in practice.
Node.js: the same idea, with more phases
Node's loop (libuv) cycles through phases: timers, pending callbacks, poll (I/O), check (setImmediate), close callbacks. Between every callback, Node drains two extra queues in a fixed order: first process.nextTick, then promise microtasks.
| API | Queue | Runs |
|---|---|---|
process.nextTick | nextTick queue | Before promise microtasks, after the current callback. Highest priority of all. |
Promise.then / await | microtask queue | After nextTick queue drains, before the loop advances. |
setTimeout(fn, 0) | timers phase | Next loop iteration at the earliest. Clamped to ≥1 ms. |
setImmediate | check phase | After the poll phase of the current iteration. Inside an I/O callback it always beats setTimeout(0). |
Rendering, long tasks and why the UI freezes
The browser can only paint between macrotasks. A task that runs for 300 ms, or a microtask chain that keeps queuing more work, blocks paint for that long. That is what a "long task" in DevTools is, and why a spinner you toggled on before a heavy loop never appears: the style you set is real, but the render step has not had its turn yet.
spinner.hidden = false;
heavyWork(); // 400 ms sync
spinner.hidden = true;
// paint happens here — with
// the spinner already hiddenspinner.hidden = false;
await new Promise(r => setTimeout(r, 0));
// a macrotask boundary: paint ran
heavyWork();
spinner.hidden = true;A promise await would not have worked in the second version. Microtasks run before render, so await Promise.resolve() yields to other microtasks but never lets the browser paint. Use a macrotask (setTimeout, MessageChannel, or scheduler.yield() where available) when you want a frame to get through. For animation work, requestAnimationFrame runs right before the next paint, which is a third position in the cycle that is neither queue.
Pitfalls
- Treating setTimeout(fn, 0) as “run immediately after this line”
It runs after the current task, after every queued microtask, after a possible render, and after any timers that were already due. In browsers nested timers are clamped to at least 4 ms. It means "later", never "now".
- Assuming a promise callback can interleave with sync code
A
thencallback can never run in the middle of a synchronous block, even if the promise is already resolved. It always waits for the stack to empty. This is also why a resolved promise still returns to the caller before its handlers fire. - Infinite microtask chains
A function that re-queues itself with
queueMicrotaskor a never-settling recursivethenchain starves rendering and every timer, because the checkpoint never ends. Re-queue with a macrotask if you need to loop. - Expecting the microtask order to survive across different tasks
Microtasks queued inside a click handler run at the end of that click task, not at the end of the script. Two separate tasks each get their own checkpoint, so ordering across them depends on which task ran first, not on when the promise was created.
Interview questions
Q1Why does a Promise.then callback run before a setTimeout(fn, 0) callback even when the timeout was registered first?
Promise reactions go on the microtask queue, which is drained completely as soon as the current task finishes. The timer enqueues a macrotask, and the loop only takes the next macrotask after the microtask checkpoint and a possible render. Registration order does not matter across the two queues.
Q2What is the difference between a microtask and a macrotask?
Both are units of work the loop runs to completion, but they come from different queues with different draining rules. One macrotask runs per loop turn; the microtask queue is emptied entirely after each task, including microtasks queued during the drain. Rendering can only happen between macrotasks.
Q3Does await block the thread?
No. It suspends only the async function. The engine returns to the caller synchronously, and the code after the await is scheduled as a microtask once the awaited promise settles. Everything else on the page keeps running.
Q4You set a loading spinner and then run a heavy computation, but the spinner never appears. Why, and how do you fix it?
The DOM change and the computation are in the same task, so the render step runs only after both finish, by which point the spinner is hidden again. Yield with a macrotask boundary before the heavy work (a zero timeout, MessageChannel, scheduler.yield), move the computation to a Web Worker, or chunk it across frames. Awaiting a resolved promise is not enough, because microtasks run before paint.
Q5In Node, what is the order of process.nextTick, Promise.then, setTimeout(0) and setImmediate inside an I/O callback?
nextTick first, then the promise callback (both run before the loop advances), then setImmediate (the check phase comes right after poll, where I/O callbacks run), then the timer on the next iteration. Outside an I/O callback, setTimeout(0) versus setImmediate is non-deterministic because it depends on whether the 1 ms timer is due when the loop starts.
Q6How can a microtask starve the UI, and how would you detect it?
If microtasks keep queuing microtasks, the checkpoint never ends, so no render or timer runs. In DevTools it shows up as a single long task in the Performance panel and as Interaction to Next Paint (INP) regressions in field data. The fix is to break the chain with a macrotask or move the work off the main thread.
Q7Where does requestAnimationFrame fit relative to the two queues?
Neither. rAF callbacks run as part of the render step, right before style, layout and paint, so they run after microtasks but at most once per frame. That makes them the right place for DOM reads and writes that must line up with a frame, and the wrong place for work that needs to happen more often than the display refreshes.
- One task at a time. The stack must be empty before anything queued can run.
- Microtasks (promises, await, queueMicrotask) drain completely after every task, including ones queued during the drain.
- Macrotasks (timers, I/O, events) run one per loop turn, and rendering only happens between them.
- setTimeout(fn, 0) means "after the current task, its microtasks and maybe a paint", never "now".
- In Node, process.nextTick beats promise microtasks, and setImmediate beats setTimeout inside I/O callbacks.
- To let the browser paint, yield with a macrotask; awaiting a promise is not enough.