The JavaScript event loop, finally explained
I've interviewed a lot of engineers, and the event loop is where confident people quietly fall apart. They can use async/await, they've sprinkled setTimeout(fn, 0) to "fix" a render bug, and they've seen the classic ordering puzzle — but ask why the output is what it is and the explanation gets vague. It's worth getting precise about, because once the model clicks, a whole class of timing bugs becomes obvious instead of mysterious.
One thread, one stack
JavaScript runs your code on a single thread. There is one call stack. When you call a function, a frame goes on; when it returns, the frame comes off. While a function is on the stack, nothing else runs — no other JS, no timer callback, no click handler. This is what people mean by "JavaScript is single-threaded": the engine does exactly one thing at a time.
So how do timers, network responses, and clicks ever run? They don't interrupt. They wait their turn. When the call stack empties — when your current chunk of synchronous code has fully finished — the runtime is free to pull the next piece of queued work and run it. The thing that decides what runs next, in what order, is the event loop.
Two queues, not one
Here's the part most explanations skip or get wrong. There isn't one queue of pending callbacks; there are two kinds of work, with different priority:
- Macrotasks (the "task queue"):
setTimeout,setInterval, I/O callbacks, message events, and — in the browser — the work that runs one user interaction or one timer firing. UI rendering happens between macrotasks. - Microtasks: Promise reactions (
.then/.catch/.finallycallbacks, the continuation after anawait),queueMicrotask, andMutationObservercallbacks.
The rule that governs everything:
After each macrotask, and after the initial run of synchronous code, the engine drains the entire microtask queue to empty before it picks up the next macrotask.
"Drains to empty" is doing a lot of work in that sentence. It doesn't run one microtask. It runs microtasks until there are none left — including any new microtasks queued while it was draining them. Only then does it consider rendering and the next macrotask. Microtasks are the line-jumpers. A setTimeout(fn, 0) scheduled before a Promise.resolve().then() still runs after it, because the timer is a macrotask and the promise reaction is a microtask, and microtasks always go first.
The classic puzzle
console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => console.log("3"));
console.log("4");
// 1, 4, 3, 2Let's walk it line by line, tracking the call stack and the two queues.
console.log("1")runs synchronously on the stack. Output:1.setTimeout(..., 0)doesn't run the callback now. It hands it to the timer subsystem; after the (at least) 0ms delay elapses, the callback() => console.log("2")is placed on the macrotask queue. Nothing prints yet.Promise.resolve().then(...)— the promise is already resolved, so its.thencallback is scheduled immediately onto the microtask queue. Still nothing prints.console.log("4")runs synchronously. Output:4.
Now the synchronous script is finished and the call stack is empty. Before the engine touches any macrotask, it drains the microtask queue. There's one microtask waiting: the .then callback.
- The microtask runs:
console.log("3"). Output:3. The microtask queue is now empty.
Only now does the event loop reach for the macrotask queue and run the timer callback.
console.log("2"). Output:2.
Final order: 1, 4, 3, 2. The two synchronous logs go first in source order. Then the microtask (3) jumps ahead of the macrotask (2), because draining microtasks happens before the next macrotask is ever picked up.
If you internalize one thing: Promise callbacks always run before the next setTimeout, even a zero-delay one.
Why setTimeout(…, 0) isn't actually 0
A few reasons, all worth knowing:
- It's a minimum, not a target.
0means "queue this as a macrotask as soon as the delay has elapsed," and the delay floor is 0, but the callback still can't run until the stack is empty and every pending microtask has drained. If your synchronous code or a chain of microtasks is hogging the thread, your "0ms" timer waits. - Browsers clamp nested timers. The HTML spec clamps
setTimeoutto a minimum of ~4ms once you're several timers deep. So a tightsetTimeout(fn, 0)recursion doesn't actually run every 0ms. - Rendering wants a turn too. The browser tries to paint between macrotasks (roughly aligned to the display's refresh, ~16.7ms at 60Hz). A flood of macrotasks can starve rendering, which is why
setTimeoutis a poor tool for animation — that's whatrequestAnimationFrameis for.
So setTimeout(fn, 0) really means "run this after the current work and the microtask queue are done, at the earliest opportunity the runtime offers" — not "run this immediately."
Where this bites you in real apps
This isn't trivia. I've debugged production incidents that came straight out of these mechanics.
Starving the UI with a microtask loop. Because the engine drains microtasks to empty before rendering or handling the next macrotask, a function that keeps queuing microtasks can lock the page completely — no paint, no input, nothing. This freezes:
function spin() {
Promise.resolve().then(spin); // queues a new microtask from inside a microtask
}
spin(); // the microtask queue never empties → the browser never gets to renderA setTimeout-based version of the same loop would be janky but survivable, because each iteration is a separate macrotask and the browser gets to render and process input between them. The microtask version never yields.
await inside a loop. Each await suspends the function and schedules its continuation as a microtask. This is sequential, not parallel:
// Sequential: each request waits for the previous one. Slow.
for (const id of ids) {
await fetchUser(id);
}
// Concurrent: kick them all off, then await together. Fast.
await Promise.all(ids.map(fetchUser));The first version isn't wrong — sometimes you need ordering or back-pressure — but reaching for it by reflex turns N independent requests into one long serial chain. Understanding that await is just "suspend and resume on the microtask queue" makes the cost visible.
The mental model to keep
Hold three things in your head and the rest follows:
- One call stack. Synchronous code runs to completion before anything queued can run.
- Microtasks drain to empty after the stack clears and after every macrotask — they jump ahead of timers and I/O.
- Macrotasks run one at a time, with microtask draining and a rendering opportunity between each.
That's the whole machine. "Why did 3 print before 2?" "Why is my setTimeout(0) late?" "Why did the tab freeze?" all collapse into the same picture. The event loop stops being magic and becomes a scheduler you can reason about.