The Event Loop — Cheat Sheet

FE Lead prep · ref 11 · §2.5 · one thread, run-to-completion · the model under INP/jank

The one rule (memorize)

1 macrotask → drain ALL microtasks → maybe render (rAF+paint, ~60fps) → repeat.
Microtasks always beat the next task & run before the next frame.

Macrotask ("task")

Microtask

The classic example

console.log('A');
setTimeout(()=>console.log('B'),0);   // macrotask
Promise.resolve().then(()=>console.log('C')); // microtask
console.log('D');
// → A  D  C  B   // sync first; microtask C before macrotask B (0ms irrelevant)
await = microtask in disguise: code before 1st await is sync; everything after await is the continuation → runs as a microtask. Post-await line is deferred.

Scheduling toolbox — where to put work

APIRunsFor
queueMicrotask/.thenmicrotask, before paintfinish current op before frame (batch state). Not heavy work.
setTimeout(fn,0)macrotask, next turn (~4ms clamp nested)yield to render/input; defer non-urgent work
requestAnimationFramejust before next paint (~60fps)visual/animation in sync w/ frame (L07 jank)
requestIdleCallbackmain thread idle (deadline)low-pri bg: analytics, prefetch — never block input
scheduler.postTask/.yieldprioritised; yield resumes after inputchunk a long task & stay responsive (modern INP fix)

Why a Lead cares (the perf model)

Chunking a long task with scheduler.yield()

async function process(items){
  let last = performance.now();
  for (const it of items){
    work(it);
    if (performance.now() - last > 50){   // yield on a ~50ms budget, NOT per item
      await scheduler.yield();             // ends task; resumes as a NEW, prioritised task
      last = performance.now();
    }
  }
}  // one 300ms task → six ~50ms tasks w/ render+input gaps → tap waits ~50ms not 300ms
ApproachTask boundary?Resume where
await scheduler.yield()yesfront — prioritised, ahead of other tasks
setTimeout(r,0)yesback of queue — can be starved
await Promise.resolve()no (microtask)same turn, before paint — frees nothing
Caveats: yielding has overhead → budget (~50ms), not per item. Doesn't cut total CPU, only slices it → heavy + non-visual ⇒ Web Worker. scheduler.yield() = Chromium today ⇒ setTimeout/postTask fallback.

Lead one-liners (memorize)

Frame  "JS is single-threaded, run-to-completion. The event loop picks what runs when the stack empties — one task, drain every microtask, then maybe paint."
Order  "Sync first, then microtasks drain before any timer — so a Promise beats a setTimeout(0) every time."
Place work  "Microtask to finish before paint; setTimeout to yield; rAF for visual; rIC/scheduler for low-priority or chunked work that mustn't block input."
Perf  "A long task owns the only thread — no input, no paint till it returns. That's the INP killer; I chunk-and-yield or move it to a Worker."

Sources: MDN — Execution model · Jake Archibald — Tasks, microtasks, queues · MDN — Microtask guide · MDN — rAF · web.dev — Optimize long tasks

Don't fail the interview

1. Order = 1 task → drain ALL microtasks → maybe paint → repeat. Not one-micro-per-turn, not paint-every-turn.
2. Microtask always beats macrotask — regardless of registration order or 0ms delay. Promise.then before setTimeout(0).
3. Classic output A D C B: sync (A,D) → microtask (C) → macrotask (B).
4. Code after await = microtask, deferred. Not synchronous.
5. Async ≠ off-thread. Wrapping blocking work in a Promise/microtask still runs on the main thread (microtask runs before paint = worse). Only yield or a Worker frees the thread.
6. Runaway microtask chain freezes harder than setTimeout loop (no paint between). Break the chain with a task.
7. Animation → rAF (not setTimeout(16), not microtask). Idle bg → rIC. Long task → scheduler.yield/Worker.
8. scheduler.yield() resumes at the front (prioritised); setTimeout(0) at the back (starvable); await Promise doesn't yield (microtask). None move work off-thread — that's a Worker.