JavaScript is single-threaded, yet it handles thousands of concurrent operations without blocking. The event loop is how — and it trips up almost everyone the first time they see the actual execution order.
The three pieces
- Call stack — where currently executing code lives. Synchronous code runs here, one frame at a time.
- Microtask queue — where
Promisecallbacks (.then,async/awaitcontinuations) go. - (Macro)task queue — where
setTimeout,setInterval, and I/O callbacks go.
The event loop's rule is simple but has a specific order: run everything on the call stack until it's empty, then drain the entire microtask queue, then run one task from the task queue, then repeat.
The classic surprising example
console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => console.log("3"));
console.log("4");Output:
1
4
3
2
Walking through it:
console.log("1")runs synchronously — call stack.setTimeoutschedules its callback on the task queue and returns immediately — it doesn't run yet, even with a 0ms delay.Promise.resolve().then(...)schedules its callback on the microtask queue.console.log("4")runs synchronously.- Call stack is now empty. The event loop drains the microtask queue:
"3"logs. - Only now does the event loop pick up the next task: the
setTimeoutcallback runs, logging"2".
Why this matters in practice
async/await continuations are microtasks. This means a chain of awaited promises will always fully resolve before a pending setTimeout fires, no matter how small its delay — a common source of confusion when debugging race conditions between timers and async data fetching.
async function load() {
const data = await fetch("/api/data").then((r) => r.json());
console.log(data); // this microtask runs before any pending setTimeout
}The practical takeaway
If you need something to run after the current synchronous code but before the browser repaints or handles the next timer, a microtask (a resolved Promise, or queueMicrotask()) gets there first. If you need to yield back to the browser entirely — letting it paint, handle input, or run other pending timers — setTimeout(fn, 0) (or requestAnimationFrame for visual work) is the tool, precisely because it waits for the next pass of the event loop instead of the current one.