The Event Loop and Microtasks
JavaScript executes one thing at a time. The event loop is the scheduler that decides what runs next, and microtasks are the priority lane it consults before anything else. This chapter assumes you are comfortable with promises, callback-based async APIs, and lexical scope.
The mental model
The runtime maintains at least these queues and stacks:
- Call stack: the currently executing synchronous frames. Only one exists; nothing runs until it empties.
- Task queue (macrotasks): callbacks from timers (
setTimeout,setInterval), I/O completion, UI events, and message channel posts. - Microtask queue: promise reaction jobs,
queueMicrotask,MutationObservercallbacks, and in Node,process.nextTick(which has its own higher-priority queue).
The loop iterates roughly as follows:
- Run all synchronous code until the call stack is empty.
- Drain the microtask queue completely — including microtasks queued by microtasks.
- If the renderer is doing updates, it may paint here (browsers only).
- Take one task from the task queue, run it to completion.
- Drain microtasks again. Repeat.
The consequence that matters: microtasks always interleave before the next macrotask and before rendering. A promise resolved inside a timer callback still runs its handlers before the next timer fires, and before the browser paints.
Observing the ordering
console.log('1: sync');
setTimeout(() => console.log('4: timer'), 0);
Promise.resolve().then(() => {
console.log('3: microtask');
});
console.log('2: sync');
// Output: 1: sync, 2: sync, 3: microtask, 4: timersetTimeout(fn, 0) does not mean "run in zero milliseconds." It means "enqueue as a task, but not before the delay, and never while the stack or microtask queue is busy." A setTimeout(..., 0) can execute well after 0 ms if the loop is backed up.
Microtask exhaustion and starvation
Because the microtask queue is drained completely, a microtask that queues another microtask can starve everything: rendering, timers, input. This never happens with tasks, since the loop takes only one task per turn.
function spin() {
queueMicrotask(spin); // infinite loop, but only at microtask priority
}
spin();
// Page freezes: timers never fire, paint never happens.This is a real failure mode in browsers: an unbounded promise chain (each .then scheduling another) blocks paint and user interaction while never blocking a single thread "officially."
Node: nextTick, microtasks, and phases
Node runs libuv's event loop with phases: timers, pending callbacks, poll, check (setImmediate), and close callbacks. Two additions complicate ordering:
process.nextTickcallbacks run before promise microtasks, in a queue with a default cap of 1000 per turn (--max-stack-sizeandnextTicklimits are separate; the 1000-drain rule prevents nextTick starvation of I/O).- Microtasks are drained between each phase transition and after each timer callback, not only after the entire loop turn.
setImmediate(() => console.log('immediate'));
setTimeout(() => console.log('timeout'), 0);
// Order between these two is NOT guaranteed inside the main module
// (libuv timing decides), but is deterministic inside an I/O callback:
const fs = require('fs');
fs.readFile(__filename, () => {
// inside the poll phase: setImmediate always wins over setTimeout
setImmediate(() => console.log('immediate'));
setTimeout(() => console.log('timeout'), 0);
});Gotchas
await is a microtask boundary. Each await suspends the async function and schedules its continuation as a microtask. But the modern spec interleaves await continuations differently from raw .then chains in edge cases — an await schedules with roughly one extra tick in older engines. Do not build logic that depends on tick counts; depend on ordering guarantees only.
Microtasks cannot abort a running task. If a synchronous loop runs for five seconds, no promise handler, no timer, no UI event runs during it. Long-running synchronous work must be chunked — either across setTimeout/setImmediate turns or via scheduler.yield() where available.
Unhandled rejection timing. Rejections are reported only after the microtask queue drains, so a try/catch added in a later tick is still in time — and a catch handler attached much later can rescue a rejection the console already flagged.
Timers in background tabs. Browsers clamp timer resolution (typically to 1s or worse for hidden tabs) and may throttle heavily. Never use setTimeout for precision; use it for scheduling.
Decision criteria: task vs microtask
| Need | Use |
|---|---|
| Yield to rendering/input between steps | `setTimeout`, `requestAnimationFrame`, `scheduler.postTask` |
| Run soon, atomically after current work | `queueMicrotask`, resolved promise |
| Node: run before promise reactions | `process.nextTick` |
| Node: run on next loop iteration, after I/O | `setImmediate` |
exercise
Exercise
What does this print, and why? console.log('a'); setTimeout(() => console.log('b'), 0); Promise.resolve().then(() => { console.log('c'); setTimeout(() => console.log('d'), 0); }); console.log('e');
Show answer
a, e, c, b, d. Sync logs 'a' and 'e' first. Then the microtask queue drains: 'c' runs and enqueues the 'd' timer as a task. The loop then takes one task: 'b'. On the next turn, 'd' fires.
Exercise
Why does replacing queueMicrotask(spin) with setTimeout(spin, 0) in the starvation example un-freeze the page?
Show answer
The loop takes only one task per turn, so each setTimeout turn lets rendering and other tasks proceed. Microtasks, by contrast, are drained fully before the loop advances, so self-perpetuating microtasks never yield.
Spotted a mistake, or code that does not work?