The Event Loop — Macrotasks, Microtasks, Rendering
TL;DR
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.
Interview Q&A
Q: Walk me through one “tick” of the event loop.
A:
- 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.
Q: What’s the output?
A:
console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => console.log("3"));
console.log("4");
// 1, 4, 3, 2
1 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.”
Q: Which callbacks are microtasks vs macrotasks?
A:
| 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.
Q: How does await fit the model?
A: 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, b
Q: What is microtask starvation?
A: 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 turn
A 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.
Q: How does Node’s event loop differ from the browser’s?
A: 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).
Q: How do you yield to the event loop to keep the UI responsive?
A: 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 ../15_performance/01_core_web_vitals.md.
Gotchas / edge cases
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 ../15_performance/.
requestAnimationFramefires before paint, not after — schedule visual updates there; reading layout inside it is already past style recalc for the previous frame.
What a senior is expected to say
- “One macrotask per tick, then the microtask queue drains completely, then render.
setTimeout(0)is ‘next macrotask,’ not ‘now.’” - “
awaitcontinuations are microtasks — code afterawaitruns before the nextsetTimeout.” - “Microtasks can starve rendering; long-running scheduling should use macrotasks or
scheduler.yield.” - “Node adds phases and
process.nextTick, which runs ahead of promises.”
Cross-references
- Promises and chaining basics: async_js/promises.md
async/awaitmechanics: async_js/async-await.md- Advanced promise APIs and
unhandledrejection: promises-advanced.md - Rendering pipeline (where rAF/paint live): ../18_browser_internals/
- Responsiveness/INP impact: ../15_performance/01_core_web_vitals.md
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