The Event Loop — Macrotasks, Microtasks, Rendering
JavaScript runs on a single thread with one call stack. Async work is scheduled onto queues that the event loop drains in a fixed order: run one macrotask to completion → drain the entire microtask queue → (in browsers) run rendering steps → next macrotask. Microtasks (Promise.then, queueMicrotask, await continuations, MutationObserver) always run before the next macrotask (setTimeout, MessageChannel, I/O, DOM events). Getting the ordering right — and knowing microtasks can starve rendering — is the classic senior probe.
Walk me through one “tick” of the event loop.
- Pull one task off the macrotask queue and run it to completion (the stack must empty).
- Drain the whole microtask queue — and any microtasks those microtasks schedule, until it’s empty.
- (Browser only) Run the render pipeline if it’s time to paint:
requestAnimationFramecallbacks → style → layout → paint. - Go back to step 1.
The key asymmetry: one macrotask per tick, but the microtask queue is drained completely before yielding.
What’s the output?
console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => console.log("3"));
console.log("4");
// 1, 4, 3, 21 and 4 are synchronous. 3 is a microtask — runs after the current synchronous run finishes but before any macrotask. 2 is a macrotask — runs last. setTimeout(…, 0) does not mean “now”; it means “after the current task and all microtasks.”
Which callbacks are microtasks vs macrotasks?
| Microtasks (drained fully each tick) | Macrotasks (one per tick) |
|---|---|
Promise.then/catch/finally |
setTimeout / setInterval |
await continuations |
setImmediate (Node) |
queueMicrotask(fn) |
MessageChannel / postMessage |
MutationObserver |
DOM events, I/O, fetch resolution dispatch |
requestAnimationFrame is neither — it runs in the render step, after microtasks, before paint.
How does await fit the model?
await x suspends the async function and schedules its continuation as a microtask when x settles. Everything after an await is effectively a .then() callback.
async function f() {
console.log("a");
await null; // suspend; resume as a microtask
console.log("b");
}
f();
console.log("c");
// a, c, bWhat is microtask starvation?
Because the loop drains the entire microtask queue before rendering or running the next macrotask, a microtask that keeps scheduling more microtasks blocks paint and timers forever:
function loop() { queueMicrotask(loop); }
loop(); // freezes the tab — rendering never gets a turnA setTimeout-based loop would not freeze rendering, because each iteration is a separate macrotask with render steps in between. Rule: use microtasks for “finish this logical unit of work now,” not for ongoing scheduling.
How does Node’s event loop differ from the browser’s?
Node runs phases (timers → pending → poll → check → close), and the microtask queue is drained between each phase, not just once per loop. Two Node-specific extras:
process.nextTick()runs before the Promise microtask queue — its own higher-priority queue. Overusing it can starve I/O.setImmediate()runs in the check phase,setTimeout(…, 0)in the timers phase; their relative order is only guaranteed when both are scheduled inside an I/O callback (thensetImmediatewins).
How do you yield to the event loop to keep the UI responsive?
Break long work into macrotasks so rendering and input can interleave:
async function chunkedWork(items) {
for (const [i, item] of items.entries()) {
process(item);
if (i % 100 === 0) await new Promise(r => setTimeout(r)); // yield
}
}Modern API: await scheduler.yield() (where supported) yields but resumes with priority. This is the lever behind good INP — see Core Web Vitals.
Gotchas
setTimeout(…, 0)is not 0ms — the HTML spec clamps nested timeouts to a minimum (≈4ms after 5 levels of nesting). For “run after microtasks” usequeueMicrotask; for “run as a fresh macrotask ASAP”MessageChannelbeatssetTimeout(0).awaitadds at least one microtask hop even when the awaited value is already resolved —await nullstill defers.Promise.resolve().then()vsqueueMicrotask()schedule onto the same queue;queueMicrotaskjust skips creating a throwaway promise.- Layout reads after writes force sync reflow — that’s a rendering cost inside a task, not an event-loop ordering issue, but it shows up in the same profiler trace. See Frontend Performance — Senior Interview Prep.
requestAnimationFramefires before paint, not after — schedule visual updates there; reading layout inside it is already past style recalc for the previous frame.
Related
Further reading
- HTML spec — Event loops: https://html.spec.whatwg.org/multipage/webappapis.html#event-loops
- Jake Archibald — “Tasks, microtasks, queues and schedules”: https://jakearchibald.com/2015/tasks-microtasks-queues-and-schedules/
- MDN —
queueMicrotask: https://developer.mozilla.org/en-US/docs/Web/API/queueMicrotask - Node.js — The event loop: https://nodejs.org/en/learn/asynchronous-work/event-loop-timers-and-nexttick
Interview angle 4
- “Walk me through one turn of the event loop.” - one macrotask runs to
completion, then the microtask queue drains entirely, then the browser may
render. That ordering is the whole model, and it is why
setTimeout(fn, 0)means “after the current task and all its microtasks”, not “now”. - “Where does the code after an
awaitrun?” - in a microtask. So it runs before any pendingsetTimeout, even one queued earlier, because the whole microtask queue drains before the next macrotask is picked up.- Follow-up: “Can that cause a problem?” - yes. A microtask that queues
another microtask can starve the loop indefinitely, and rendering never
happens because the queue never empties. Long runs of work belong in
macrotasks or behind
scheduler.yield().
- Follow-up: “Can that cause a problem?” - yes. A microtask that queues
another microtask can starve the loop indefinitely, and rendering never
happens because the queue never empties. Long runs of work belong in
macrotasks or behind
- “Why does a long task make the page feel broken?” - rendering and input handling only get a turn between macrotasks, so a 300ms task means 300ms with no paint and no response to a click. Splitting the work is the fix; making it faster is often not enough on a slow device.
- “Does Node behave the same way?” - the same microtask rule, but the
macrotask side is split into phases - timers, poll, check - and it adds
process.nextTick, which runs before promise callbacks. Code that assumes browser ordering can reorder subtly when it moves to Node.