Overview
JavaScript runs on a single call stack, and the event loop is what decides what runs next once that stack is empty: it drains the entire microtask queue (Promise callbacks, queueMicrotask) completely before pulling even one macrotask (setTimeout, setInterval, I/O, UI events) from the task queue, then repeats. Every job, whether a task or a microtask, runs to completion once started, JavaScript cannot pause a function mid-execution to interleave another callback, which is what makes reasoning about shared state inside a single synchronous function safe without locks. The one rule that resolves almost every "which callback runs first" question is simple to state and easy to forget under pressure: microtasks always drain completely before the next macrotask, regardless of a timeout's delay value or registration order.
Quick Reference
| Queue | Examples | When it runs |
|---|---|---|
| Call stack | Currently executing code | Immediately, synchronously |
| Microtask queue | Promise.then/catch/finally, queueMicrotask | Fully drained before the next macrotask |
| Macrotask (task) queue | setTimeout, setInterval, I/O, UI events | One task per event loop iteration, after microtasks are drained |
Syntax
console.log('sync 1');
setTimeout(() => console.log('macrotask'), 0);
Promise.resolve().then(() => console.log('microtask'));
console.log('sync 2');
// Output: sync 1, sync 2, microtask, macrotaskExamples
// Even a 0ms delay doesn't mean "immediately" - the entire
// microtask queue drains first, every time.
const p = Promise.resolve();
p.then(() => console.log('first')); // microtask
p.then(() => console.log('second')); // microtask
setTimeout(() => console.log('third'), 0); // macrotask
// Logs: first, second, third - always, regardless of timing.A chain of self-scheduling microtasks can starve macrotasks
If a microtask schedules another microtask while running, the new one is still part of the queue being drained before the next macrotask. A recursive chain of microtasks can, in principle, delay timers and rendering indefinitely, this isn't hypothetical, it's a direct consequence of "drain completely," not "drain what was queued at the start."
Visual Diagram
Common Mistakes
- Assuming
setTimeout(fn, 0)runs "immediately" or before a Promise callback registered afterward; microtasks always drain first, regardless of delay value. - Treating "run to completion" as unique to microtasks; it applies to every job, tasks included, JavaScript never preempts a running function mid-execution.
- Not realizing a microtask that schedules further microtasks keeps extending the current drain cycle, potentially starving macrotasks like rendering and timers.
- Debugging async ordering by trial and error instead of applying the one rule (microtasks fully drain before the next macrotask) directly.
Performance
- A single call stack means CPU-bound synchronous work blocks everything else, the event loop, rendering, timers, until that function returns; long synchronous functions are a direct cause of an unresponsive page.
- An unbounded chain of self-scheduling microtasks can delay macrotasks (including rendering) indefinitely, a real performance failure mode, not just a theoretical curiosity.
Best Practices
- Reason about async ordering from the one rule, microtasks drain completely before the next macrotask, rather than guessing from registration order or delay values.
- Break up long synchronous, CPU-bound work (large loops, heavy computation) so it doesn't block the call stack and starve rendering/timers.
- Be deliberate about chains of Promise callbacks that schedule further Promise callbacks; understand that they extend the current microtask drain rather than yielding to macrotasks.
- Use
setTimeout(fn, 0)(or similar) specifically when you need to yield to the macrotask queue, letting pending rendering or other tasks run, not as a "run immediately" mechanism.