One call stack, run-to-completion, and a strict order for what runs in the gaps: sync → drain all microtasks → maybe render → next task. Get that one rule and the “tricky ordering” puzzle, the Promise-vs-setTimeout question, and why a long task freezes the page all fall out of it.
Best visual explainer of the call stack and the task/microtask queues. Watch it once before reading — then the §3 ordering example below will click instantly. Open on YouTube ↗
▶ click to play
JavaScript runs on one thread with one call stack. The cardinal rule is run-to-completion: once a function starts, it runs to the end without interruption — no other JS (no event handler, no timer, no promise callback) can sneak in mid-function. The event loop is simply the mechanism that, whenever the stack is empty, decides what to run next. [MDN]
It chooses from two queues, and the distinction between them is the whole lesson:
setTimeout/setInterval, I/O, message/UI events (click, etc.), postMessage. The loop runs exactly one task, then moves on.
Promise .then/.catch/.finally, await continuations, queueMicrotask, MutationObserver. After each task, the loop drains all of these before doing anything else.
“JS is single-threaded and run-to-completion. The event loop just picks what runs when the stack empties — one task, then it drains every microtask, then it may paint.”
Memorize this cycle; almost every interview question is a corollary of it: [Jake Archibald]
<script>, or one setTimeout callback. It runs to completion.requestAnimationFrame callbacks, then style → layout → paint.Two consequences carry most of the weight:
Promise.then queued after a setTimeout(…,0) still runs first — the whole microtask queue drains before the next task.“The cycle is: one task, then drain all microtasks, then maybe paint. Microtasks always run before the next task and before the next frame.”
The question you'll get asked. Predict the output before reading on. (Watching the queues move helps it stick — Lydia Hallie's animated event-loop walk-through is the best visual for this.)
console.log('1 · script start');
setTimeout(() => console.log('2 · setTimeout'), 0); // macrotask
Promise.resolve()
.then(() => console.log('3 · promise A')) // microtask
.then(() => console.log('4 · promise B')); // microtask (chained)
console.log('5 · script end');
Why, step by step:
setTimeout and .then only queue callbacks; they don't run yet..then and queues 4; the loop keeps draining → 4 runs. Both microtasks clear before any task.await is a microtask in disguise. Everything after an await is the promise continuation — it runs as a microtask, not synchronously. So async function f(){ console.log('a'); await x; console.log('b'); } logs 'a' now and schedules 'b' as a microtask. People forget the post-await line is deferred and create subtle ordering bugs.
“Sync first, so 1 then 5. Then microtasks drain — 3, 4 — before any timer. The setTimeout is a macrotask, so it's dead last even at 0ms.”
A Lead doesn't just know the order — they know which queue to choose for a piece of work. The map: [MDN]
| API | Runs | Reach for it when… |
|---|---|---|
queueMicrotask / Promise.then | microtask — after current task, before render | you need to act right after the current op, before paint (e.g. batch a state update). Don't put heavy work here. |
setTimeout(fn, 0) | macrotask — a future loop turn (nested calls clamped to ~4ms) | you want to yield to rendering/input — defer non-urgent work so the browser can paint first. |
requestAnimationFrame | just before the next paint (~60fps) | visual/animation updates — read/write layout in sync with the frame to avoid jank (Lesson 07). |
requestIdleCallback | when the main thread is idle (with a deadline) | low-priority background work (analytics, prefetch) that must never block interaction. |
scheduler.postTask / scheduler.yield | prioritised tasks; yield resumes after the browser handles input | breaking a long task into chunks while staying responsive — the modern INP fix (Lesson 07). |
“Microtask to finish the current job before paint; setTimeout to yield; rAF for visual work in time with the frame; rIC/scheduler for low-priority or chunked work that mustn't block input.”
This isn't trivia; it's the engine under every responsiveness problem you've studied:
scheduler.yield/setTimeout) or move it to a Web Worker.setTimeout loop would, because tasks at least yield between turns. Don't recurse in microtasks; use a task to break the chain.scheduler.yield()The headline INP fix, made concrete. You don't make the work smaller — you make it interruptible. Break the loop into chunks and, between them, hand the thread back so the browser can paint and dispatch pending input, then resume. [web.dev]
async function process(items) {
let last = performance.now();
for (const item of items) {
doExpensiveWork(item);
if (performance.now() - last > 50) { // yield on a ~50ms budget, not per item
await scheduler.yield(); // ← ends this task; resumes as a new, prioritised task
last = performance.now();
}
}
}
What await scheduler.yield() does: it ends the current task right there. The rest of the loop becomes a new task; in the gap the loop is free to render and dispatch queued input, then your continuation runs. One 300ms task becomes six ~50ms tasks with usable gaps — a tap now waits ~50ms (one chunk), not 300ms.
Why scheduler.yield() specifically, and not the obvious alternatives:
| Approach | Real task boundary? | Where you resume |
|---|---|---|
await scheduler.yield() | yes | front of the line — continuation is prioritised, ahead of other pending tasks |
setTimeout(resolve, 0) | yes | back of the queue — behind every already-queued task (your work can starve) |
await Promise.resolve() | no — it's a microtask | same loop turn, before paint — frees nothing |
The await Promise row is the trap from the quiz (Q10): a microtask doesn't yield to render/input. The setTimeout(0) row works but sends your continuation to the back of the task queue, so unrelated tasks delay it. scheduler.yield() is the one that yields and resumes with priority — yield to the browser without losing your place.
scheduler.yield() is Chromium today — pair it with a setTimeout/scheduler.postTask fallback for other engines.
“A long task is selfish — run-to-completion means it owns the thread till it returns. scheduler.yield() slices it so the browser paints and handles input between slices, and resumes my work with priority — unlike setTimeout(0) (back of the queue) or await Promise (doesn't yield at all).”
scheduler.yield) when responsiveness matters more than immediacy. Choosing wrong is how you get either jank or stale frames.
Concept: one thread, run-to-completion; the loop runs a task, drains all microtasks, then maybe paints. Trade-off: microtasks finish work before paint but can starve rendering; tasks yield but defer work — pick by whether correctness-before-paint or responsiveness wins. Anchor: “We had an input that froze on a big list; it was a synchronous filter on the main thread blocking the loop. We chunked it with scheduler.yield so input could interrupt — INP dropped under 200ms.” Impact: the loop is the model for INP/jank at platform scale on low-end devices — knowing it is how you place work, not guess. Invite: “If the work were CPU-heavy and non-visual, I'd skip scheduling tricks and move it to a Web Worker off the main thread entirely.”
Pick an answer; instant feedback. Push-back style, like the round.
1. What does "run-to-completion" mean, and why does it matter?
2. State the event loop's order of operations in one line.
3. Predict the output:
scn: console.log('A'); setTimeout(()=>console.log('B'),0); Promise.resolve().then(()=>console.log('C')); console.log('D');
4. A Promise.then is registered after a setTimeout(fn, 0) in the code. Which callback runs first, and why?
5. What runs as a microtask in async function f(){ console.log('a'); await g(); console.log('b'); }?
6. Why can a runaway microtask chain freeze the page harder than an equivalent setTimeout loop?
7. You need to update an animation's position smoothly every frame. Which API?
8. Low-priority work: send analytics and prefetch the next page's data, without ever delaying user interaction. Best fit?
9. An input handler runs a 300ms synchronous computation and the page goes unresponsive. Best Lead fix?
10. Why does wrapping blocking work in a Promise (without yielding) not make the page responsive?
11. When is a microtask the right choice over yielding with a task?
12. You chunk a long task and yield between slices. Why is await scheduler.yield() better than await new Promise(r => setTimeout(r, 0))?
0 / 12 answered
console.log → setTimeout(0) → Promise.then → console.log, and tell me how the loop explains a long task tanking INP.” Time to 90 seconds.