JavaScript Event Loop Table
| Stage | What runs there | Field note |
|---|---|---|
Call stack | The one running function | Single-threaded means the stack, not a thread pool - blocking code is a busy stack |
Microtask queue | Promise .then/.catch/.finally, queueMicrotask, await resumes | Drains COMPLETELY after each task - before rendering, before the next timer |
Macrotask queue | setTimeout, setInterval, events, I/O callbacks | ONE task per loop turn - timers are a minimum wait, never exact |
requestAnimationFrame | Visual callbacks, before the next paint | Synced to the frame, not to the timer queue - cancelled when the tab hides |
Rendering | Style, layout, paint | Happens after microtasks drain and rAF runs - skipped entirely for hidden tabs |
Starvation | Microtasks scheduling microtasks | queueMicrotask(self) never yields - tasks AND rendering starve; timers re-queue, so they yield |
JavaScript runs one loop, not threads: it picks ONE task, runs it to completion on the call stack, drains the entire microtask queue, then - only if something changed and the tab is visible - renders. Every ordering surprise in asynchronous JavaScript decodes from that sequence.
Bottom line: the famous setTimeout(fn, 0) vs promise ordering question is just queue priority - timers are macrotasks, .then callbacks are microtasks, and the microtask queue always drains completely between tasks, so promises cut line no matter who registered first. Every await is the same mechanism: the rest of the function resumes as a microtask.
The honest part: single-threaded does not mean slow - it means a long task blocks EVERYTHING. A 200-millisecond loop on the stack delays timers, click handlers and rendering alike, which is why chunking work across tasks (or moving it to a worker) is the real fix, not a faster machine.
How to use
- Order code deliberately: if a callback must run after the current task but before any rendering, put it in queueMicrotask or a promise .then.
- Chunk long work: move batches into setTimeout(fn, 0) steps so each task stays short - the loop gets breathing room for events and paint.
- Use requestAnimationFrame for visual changes - it runs once per frame before paint, instead of racing timers that may fire twice or not at all.
Frequently asked questions
Why does a promise .then fire before setTimeout(fn, 0)?
Queue priority. setTimeout registers a MACROtask - the loop picks up one macrotask per turn. Promise reactions are MICROtasks, and after the current task finishes, the loop drains the entire microtask queue before touching the next macrotask. So even a timer registered first waits behind every microtask that exists at drain time. The classic interview snippet (setTimeout log, promise log, sync log) resolves as: sync first (the stack), then the microtask, then the timer - exactly one rule, zero memorization of examples.
What counts as a microtask and what counts as a task?
Microtasks: promise reactions (.then/.catch/.finally), await resumptions, queueMicrotask callbacks and MutationObserver. Tasks: setTimeout and setInterval callbacks, event handlers (click, input, message), I/O completions and most everything the platform schedules. The design intent: microtasks are 'finish the current logical operation' steps - a promise chain should settle completely before the browser moves on - while tasks are independent units of work the scheduler can interleave with rendering and input.
Can the event loop starve, and how?
Yes - schedule a microtask that schedules itself (queueMicrotask(step) at the end of step) and the loop drains that queue forever: no timers, no events, no rendering. Microtasks must terminate; that is the contract. Timers cannot starve the loop this way because each setInterval/setTimeout callback is a separate TASK, and between tasks the loop serves everything else. Infinite while loops starve it harder still - the stack never empties - which is the same failure at a coarser grain.
Where does requestAnimationFrame fit relative to timers?
rAF callbacks run in the RENDERING step, once per frame, after microtasks have drained - not in the timer queue at all. That placement guarantees your visual change is applied exactly once per painted frame: a resize handler fired five times in one frame plus a rAF callback runs the callback once. Timers cannot promise that (a 16ms interval can fire 0 or 2 times per frame when the frame rate drifts). This is why animation code moved from setInterval to rAF decades ago - it syncs to when pixels actually update.