The Event Loop

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.

▶ Watch this first

The JavaScript Event Loop — Lydia Hallie

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 ↗

The JavaScript Event Loop — Lydia Hallie (click to play on YouTube) ▶ click to play

1The frame — one thread, run-to-completion

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:

Macrotask queue (“tasks”)

one per loop turn

setTimeout/setInterval, I/O, message/UI events (click, etc.), postMessage. The loop runs exactly one task, then moves on.

Microtask queue

drained completely, every turn

Promise .then/.catch/.finally, await continuations, queueMicrotask, MutationObserver. After each task, the loop drains all of these before doing anything else.

One-liner

“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.”

2The one rule — the loop's order of operations

Memorize this cycle; almost every interview question is a corollary of it: [Jake Archibald]

  1. Run one macrotask — e.g. the initial <script>, or one setTimeout callback. It runs to completion.
  2. Drain the entire microtask queue — run microtasks until the queue is empty, including microtasks scheduled by other microtasks. This happens after every task.
  3. Maybe render — if it's time for a frame (~every 16.7ms at 60fps, not every turn): run requestAnimationFrame callbacks, then style → layout → paint.
  4. Back to step 1 — pick the next macrotask.

Two consequences carry most of the weight:

One-liner

“The cycle is: one task, then drain all microtasks, then maybe paint. Microtasks always run before the next task and before the next frame.”

3Walk the classic example

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');
1 · script start
5 · script end
3 · promise A
4 · promise B
2 · setTimeout
▶ Watch the loop run it — step by step Step 0 / 10
sync timer (Web API) microtask macrotask
Call Stack LIFO
Web APIs timers, fetch…
⟳ Event Loop — idle
Microtask Queue drained fully
Macrotask Queue one per turn
console
The whole script is one macrotask. Press Play (or Next) to run it line by line and watch what lands in each queue.

Why, step by step:

Trap — 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.
One-liner

“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.”

4The scheduling toolbox — where to put work

A Lead doesn't just know the order — they know which queue to choose for a piece of work. The map: [MDN]

APIRunsReach for it when…
queueMicrotask / Promise.thenmicrotask — after current task, before renderyou 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.
requestAnimationFramejust before the next paint (~60fps)visual/animation updates — read/write layout in sync with the frame to avoid jank (Lesson 07).
requestIdleCallbackwhen the main thread is idle (with a deadline)low-priority background work (analytics, prefetch) that must never block interaction.
scheduler.postTask / scheduler.yieldprioritised tasks; yield resumes after the browser handles inputbreaking a long task into chunks while staying responsive — the modern INP fix (Lesson 07).
One-liner

“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.”

5Why a Lead cares — the loop is your perf model

This isn't trivia; it's the engine under every responsiveness problem you've studied:

Deep dive — chunking a long task with 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:

ApproachReal task boundary?Where you resume
await scheduler.yield()yesfront of the line — continuation is prioritised, ahead of other pending tasks
setTimeout(resolve, 0)yesback of the queue — behind every already-queued task (your work can starve)
await Promise.resolve()no — it's a microtasksame 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.

Trade-off — yielding is cooperative, not free, and not off-thread. Each yield is a task boundary with overhead, so yielding per item makes the whole job slower (death by a thousand cuts) — yield on a time budget (~50ms). It also doesn't cut the total CPU work, only slices it; if the work is heavy and non-visual, a Web Worker (truly off the main thread) beats yielding. And scheduler.yield() is Chromium today — pair it with a setTimeout/scheduler.postTask fallback for other engines.
One-liner

“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).”

Trade-off — microtask (finish now) vs task (yield first). A microtask runs sooner (before paint) but delays the frame and the user's next interaction; a task yields to rendering/input but runs later. So: use a microtask when correctness needs it done before the next paint; use a task (or scheduler.yield) when responsiveness matters more than immediacy. Choosing wrong is how you get either jank or stale frames.
Full loop

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.”

7Check yourself — scenario quiz

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

Try this aloud before next session: “Walk me through the event loop: one thread, the two queues, and the exact order things run. Then predict the output of console.log → setTimeout(0) → Promise.then → console.log, and tell me how the loop explains a long task tanking INP.” Time to 90 seconds.
Good follow-up topics:
“Quiz me out loud, harder” “Walk a harder rAF + microtask example” “Node event loop vs the browser's?” “Why the 4ms setTimeout clamp?” “How does scheduler.yield differ from await null?” “Where do Web Workers sit in all this?”