The Event Loop in Depth
JavaScript runs on a single thread, yet it juggles timers, network responses, and user input without blocking. The event loop is the coordinator that makes this possible: it decides when each piece of queued work runs and in what order. Understanding it removes the mystery from “why did this log print before that?” and lets you reason precisely about asynchronous code, avoid UI jank, and write predictable programs.
The mental model
At any moment the runtime holds a few distinct structures. The call stack tracks the function currently executing. When the stack empties, the event loop checks two queues. The microtask queue holds promise callbacks (.then/.catch/.finally), await continuations, and anything scheduled with queueMicrotask. The macrotask queue (also called the task queue) holds callbacks from setTimeout, setInterval, I/O, and DOM events.
The single most important rule: after every single macrotask, the engine drains the entire microtask queue before doing anything else — including rendering or picking up the next macrotask.
┌────────────────────────┐
│ Call Stack │ <- runs sync code to completion
└────────────────────────┘
│ (stack empty)
▼
┌─────────────────────────────────────┐
│ Drain ALL microtasks (promises, │ <- runs to exhaustion
│ await continuations, queueMicrotask)│
└─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ (Browser) Render: rAF, style, paint│
└─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ Take ONE macrotask (setTimeout, I/O)│ ──┐
└─────────────────────────────────────┘ │
▲ │
└─────── loop ────────────┘
A worked ordering example
This snippet mixes synchronous code, a timer, a resolved promise, and an explicit microtask. Predict the output before reading on.
console.log("1: sync start");
setTimeout(() => console.log("2: setTimeout (macrotask)"), 0);
Promise.resolve().then(() => console.log("3: promise .then (microtask)"));
queueMicrotask(() => console.log("4: queueMicrotask (microtask)"));
console.log("5: sync end");
Output:
1: sync start
5: sync end
3: promise .then (microtask)
4: queueMicrotask (microtask)
2: setTimeout (macrotask)
The reasoning, step by step:
- Lines
1and5are synchronous, so they run first, top to bottom, while the call stack is busy. setTimeoutregisters a macrotask;.thenandqueueMicrotaskregister microtasks.- The stack empties. The loop drains all microtasks now:
3then4, in the order they were queued. - Only after the microtask queue is empty does the loop pick up the single macrotask:
2.
A
setTimeout(fn, 0)does not run “immediately.” It runs after the current task and the entire microtask queue have completed — often several milliseconds later.
Where await fits
await is syntactic sugar over promise continuations. Everything after an await is scheduled as a microtask once the awaited value settles.
async function run() {
console.log("A");
await null; // suspends; resumes as a microtask
console.log("C");
}
console.log("start");
run();
console.log("B");
Output:
start
A
B
C
A logs synchronously up to the await. Control returns to the caller (B logs), and only when the stack clears does the continuation (C) run as a microtask.
Microtasks vs macrotasks
| Source | Queue | Drained | Typical use |
|---|---|---|---|
Promise.then / await | Microtask | Fully, after each task | Sequencing async results |
queueMicrotask | Microtask | Fully, after each task | Defer work, keep promise ordering |
setTimeout / setInterval | Macrotask | One per loop turn | Timers, yielding to the UI |
| DOM events, I/O callbacks | Macrotask | One per loop turn | User input, network |
requestAnimationFrame | Render phase | Before paint (~60fps) | Smooth animations |
Starvation: when microtasks never end
Because the loop refuses to advance until the microtask queue is empty, a microtask that schedules another microtask can starve the loop forever. Rendering and timers will never get a turn.
// DANGER: this freezes the page — the microtask queue never empties
function flood() {
queueMicrotask(flood);
}
flood();
To yield back to the event loop — allowing rendering and other tasks to run — schedule continuation work as a macrotask instead:
function chunk(items, i = 0) {
const end = Math.min(i + 1000, items.length);
for (; i < end; i++) process(items[i]);
if (i < items.length) {
setTimeout(() => chunk(items, i), 0); // yields to the loop
}
}
Rendering and animation timing
In the browser, the loop interleaves a render step between macrotasks (roughly aligned to the display’s refresh rate). Callbacks registered with requestAnimationFrame run just before paint, after microtasks but before the browser draws the frame. This makes rAF the correct place to mutate the DOM for animation.
function animate() {
el.style.transform = `translateX(${x++}px)`;
requestAnimationFrame(animate); // re-schedules for the next frame
}
requestAnimationFrame(animate);
Avoid layout work inside
setTimeoutloops for animation: the timing drifts and you can paint multiple times per frame.requestAnimationFramesynchronizes precisely with the render step.
Node.js differences
Node uses libuv and adds phases the browser lacks. process.nextTick callbacks run before the promise microtask queue, and setImmediate fires in a dedicated check phase after I/O. The microtask-after-every-task rule still holds, but the macrotask ordering is phase-based rather than a single queue.
Best Practices
- Reach for
queueMicrotaskwhen you need to defer work but preserve promise ordering; usesetTimeout(fn, 0)when you specifically want to yield to rendering and other tasks. - Never assume
setTimeout(fn, 0)is instant — treat it as “after the current task and all microtasks.” - Break long synchronous loops into chunks that yield via a macrotask to keep the UI responsive.
- Avoid recursively scheduling microtasks; it starves rendering and timers.
- Do DOM mutations for animation inside
requestAnimationFrame, not in timers. - Remember
awaitresumes as a microtask — code after it never runs in the same tick. - In Node, account for
process.nextTickrunning before promise microtasks andsetImmediaterunning in the check phase.