Frontend
The JavaScript Event Loop & Async Execution
Why a Promise.then always beats a setTimeout(fn, 0) — the call stack, the microtask queue, and the macrotask queue, explained in the order they actually run.
Ask almost any frontend interviewer for their favorite trick question and you'll get some version of: "what does this code print, and in what order?" It looks like a simple question about setTimeout and Promise, but it's really a question about whether you understand how JavaScript actually executes asynchronous code — because the language itself is not asynchronous at all. It's aggressively, single-mindedly synchronous, and everything "async" is a trick played by the environment around it.
The call stack: one thing at a time
JavaScript has a single call stack. When a function is invoked, a frame is pushed onto the stack; when it returns, the frame is popped. As long as there's a frame on the stack, the engine is busy running it and cannot run anything else — no other callback, no timer, no event handler gets a turn until the stack is empty. This is what people mean when they say JavaScript is single-threaded: there is exactly one call stack, so exactly one piece of JavaScript can be executing at any instant.
Everything else — timers, network responses, DOM events, Promise resolutions — happens outside that stack, in the surrounding runtime (the browser or Node.js). Those pieces of work get handed to the engine only when the stack is empty, and that handoff is managed by the event loop.
Two queues, not one
The part most people get wrong is assuming there's a single queue of "stuff waiting to run." There are actually two, and they are not treated equally:
- The macrotask queue (sometimes just called "the task queue") holds things like
setTimeout/setIntervalcallbacks, I/O completions, and UI events like clicks. - The microtask queue holds Promise callbacks (
.then,.catch,.finally) and anything scheduled withqueueMicrotask.
The event loop's rule is: after the currently running script finishes and the call stack is empty, drain the entire microtask queue — every microtask, including any new ones that got queued while draining — before touching the next macrotask. Only once the microtask queue is completely empty does the loop pull the next macrotask off its queue and run it.
The rule interviewers are actually testing
Microtasks always run before the next macrotask, no matter which was scheduled first. A Promise.then callback beats a setTimeout(fn, 0) every single time, even though the timeout was written first — because "0ms" only means "as soon as possible once it's this queue's turn," and the microtask queue always gets its turn first.
Walking through the classic example
This is the exact shape of code that shows up in interviews. Try to predict the output before reading the explanation below it.
console.log("1: sync start");
setTimeout(() => {
console.log("2: macrotask (setTimeout)");
}, 0);
Promise.resolve().then(() => {
console.log("3: microtask (promise.then)");
});
console.log("4: sync end");
// Output:
// 1: sync start
// 4: sync end
// 3: microtask (promise.then)
// 2: macrotask (setTimeout)Here's why it prints in that order, step by step:
console.log("1: sync start")runs immediately — it's synchronous code sitting directly on the call stack. Prints 1.setTimeout(...)is called. It doesn't run the callback — it hands the callback to the runtime's timer system, which will push it onto the macrotask queue once the delay elapses (0ms in practice still means "after the current script and pending microtasks", not "instantly").Promise.resolve().then(...)is called. The promise is already resolved, so its callback is scheduled immediately onto the microtask queue — but scheduled is not the same as run. It still has to wait for the current synchronous code to finish.console.log("4: sync end")runs next because the stack still hasn't emptied — there's no reason to yield yet. Prints 4.- The synchronous script is now finished and the call stack is empty. Before the event loop even looks at the macrotask queue, it drains the microtask queue: the
.thencallback runs, printing 3. - Only now, with the microtask queue empty, does the event loop pull the
setTimeoutcallback off the macrotask queue and run it, printing 2.
So the final order is 1, 4, 3, 2 — not 1, 4, 2, 3. The "0 milliseconds" in setTimeout(fn, 0) is a minimum delay before the callback becomes eligible to run, not a promise that it runs before anything else scheduled by other means.
Why this matters for rendering, too
The event loop's task-vs-microtask split isn't just trivia — it affects when the browser actually paints. Browsers typically get a chance to render between macrotasks, not between individual microtasks. That means a long chain of chained .then calls, or a recursive queueMicrotask, can keep the microtask queue busy indefinitely and starve the browser of any opportunity to paint or respond to input — the page looks frozen even though, strictly speaking, no single task ran too long. If you need to do a large amount of work without blocking the UI, breaking it up across setTimeout calls (yielding to a macrotask boundary) is often the right move precisely because it lets a render happen in between.
Microtask starvation
A microtask that queues another microtask, which queues another, and so on, can run forever without ever letting the browser paint — because the loop won't move to the next macrotask (and the render opportunity that comes with it) until the microtask queue is empty. This is a real production bug pattern, not just an academic curiosity.
Node.js: similar idea, different machinery
Node.js doesn't run in a browser, so there's no rendering step, but it still separates synchronous code, microtasks, and macrotasks the same way. What's different under the hood is that Node's macrotask queue is actually several ordered phases managed by libuv — timers, pending callbacks, I/O polling, setImmediate, and close callbacks each get their own phase, and microtasks drain between every phase transition. You don't need to memorize the phase names for a frontend interview, but it's worth knowing the concept doesn't port over 1:1 — "the event loop" in Node and "the event loop" in a browser share a philosophy, not an implementation.
Key takeaways
- JavaScript runs on one call stack — only one piece of code executes at a time; async work happens in the runtime around it, not on the stack itself.
- There are two queues: the microtask queue (Promise callbacks, queueMicrotask) and the macrotask queue (setTimeout, I/O, UI events).
- After the current synchronous code finishes, the entire microtask queue drains before the next macrotask runs — that's why Promise.then beats setTimeout(fn, 0) every time.
- setTimeout(fn, 0) means 'eligible to run after 0ms and after pending microtasks,' not 'runs immediately' or 'runs before Promises.'
- Browsers generally paint between macrotasks, not between microtasks — a long microtask chain can block rendering entirely.
- Node.js follows the same synchronous-first, microtasks-before-next-task model, but its macrotask queue is split into libuv phases (timers, I/O, setImmediate, close callbacks) that don't map exactly onto browser behavior.
