The event loop, microtasks vs macrotasks, and the famous output trap
JavaScript runs on one thread. One. That is the whole reason async exists. If a single click handler blocked the thread for 200ms, the page would freeze.
The event loop is the traffic cop that lets a single-threaded language feel non-blocking. Master it and you will never again be surprised by output order. This is the single most-asked question in senior JS interviews.
The pieces on the table
- Call stack. Where synchronous code runs. One frame at a time.
- Web APIs (or Node APIs). The browser or Node itself owns these.
setTimeout,fetch, DOM events. They run off-thread. - Macrotask queue (a.k.a. task queue). Holds
setTimeout,setInterval, I/O callbacks, UI events. - Microtask queue. Holds
Promise.then,queueMicrotask,MutationObservercallbacks. - Event loop. A simple loop: if the stack is empty, drain the microtask queue completely, then take one macrotask, then repeat.
The key word in that diagram is all. Microtasks drain completely. If a microtask queues another microtask, that one runs in the same drain cycle before any macrotask gets a turn.
The famous output trap
Every interviewer asks some version of this. Predict the output.
console.log("A");
setTimeout(() => console.log("B"), 0);
Promise.resolve().then(() => console.log("C"));
console.log("D");Step-by-step.
- Step 1.
console.log("A")runs synchronously. PrintsA. - Step 2.
setTimeouthands the callback to the Web API. After 0ms it goes into the macrotask queue. - Step 3.
Promise.resolve().then(...)queues its callback into the microtask queue. - Step 4.
console.log("D")runs synchronously. PrintsD. Call stack is now empty. - Step 5. Event loop checks microtasks first. Drains. Prints
C. - Step 6. Event loop takes one macrotask. Prints
B.
Output: A D C B. Not A D B C. The Promise wins because microtasks always drain before the next macrotask.
Interview trap:
setTimeout(fn, 0)is not zero. The 0 is a minimum delay before the task is eligible to run. Browsers also clamp nested timeouts to 4ms after several layers of nesting.
A realistic Swiggy example
console.log("Order placed");
setTimeout(() => console.log("SMS sent"), 0);
fetch("/charge").then(() => {
console.log("UPI charged");
Promise.resolve().then(() => console.log("Receipt logged"));
});
console.log("Showing loader");Assuming fetch resolves quickly, the order is roughly.
Order placed(sync).Showing loader(sync).- Microtasks drain.
UPI charged, then the queuedReceipt loggedin the same drain. - Macrotask runs.
SMS sent.
Notice that the SMS line, scheduled with setTimeout(fn, 0), runs AFTER both Promise callbacks. Even though it had a "0" delay. This is the single most common production bug, scheduling work with setTimeout and expecting it to beat a Promise.
Microtasks can starve macrotasks
Because microtasks drain completely, a runaway Promise chain can block the macrotask queue forever.
function spin() {
Promise.resolve().then(spin);
}
spin(); // browser freezes, setTimeout callbacks never fireEach then queues another microtask. The loop never gets to take a macrotask. The page becomes unresponsive. Useful trap to know exists, never useful to write.
If you genuinely need to yield to rendering, use setTimeout(fn, 0) or requestAnimationFrame. Both go through the macrotask path and give the browser a chance to paint.
Render step, between macrotasks
In browsers, the render step (style, layout, paint) happens between macrotasks, after microtasks have drained. Mutating the DOM inside a tight Promise chain delays the paint. That is why long synchronous loops feel laggy and async chunks feel smooth.
// Bad: blocks paint
for (let i = 0; i < 1_000_000; i++) updateCell(i);
// Better: yields between chunks
async function paintChunks() {
for (let i = 0; i < 1_000_000; i++) {
updateCell(i);
if (i % 1000 === 0) await new Promise(r => setTimeout(r, 0));
}
}The await setTimeout yields a macrotask, lets the browser paint, then resumes.
Quick reference
- Call Stack
- Where sync JS executes, one frame at a time.
- Macrotask
- A task from setTimeout, setInterval, I/O, or a UI event. One runs per event loop tick.
- Microtask
- A callback from Promise.then, queueMicrotask, or MutationObserver. All drain after each macrotask.
- Event Loop
- The loop that, when the stack is empty, drains microtasks then runs one macrotask.
Predict the output, harder version
console.log(1);
setTimeout(() => console.log(2), 0);
Promise.resolve()
.then(() => console.log(3))
.then(() => console.log(4));
console.log(5);Step by step.
- Step 1. Sync prints
1. - Step 2.
setTimeoutqueues macrotask2. - Step 3. First
.thenqueues microtask3. The second.thenis NOT queued yet, it only queues once the first resolves. - Step 4. Sync prints
5. Stack empty. - Step 5. Drain microtasks.
3runs, which causes the next.thento queue. Still in the same drain cycle.4runs. - Step 6. Take one macrotask.
2runs.
Output: 1 5 3 4 2.
Senior rule: microtasks drain completely before the next macrotask runs. If you can recite that one line and walk through a five-step trace on a whiteboard, you have passed the async section of every senior JS interview I have seen.
Quick quiz, prove you got it
- 1.Predict the output: `console.log("A"); setTimeout(() => console.log("B"), 0); Promise.resolve().then(() => console.log("C")); console.log("D");`
- 2.Which queue does `Promise.resolve().then(fn)` schedule fn on?
- 3.Why does `setTimeout(fn, 0)` not run immediately?
Interview insights
When asked "explain the event loop"
- JS is single-threaded; the event loop coordinates the stack with task queues
- Web APIs / Node APIs hand work off and notify when done
- Microtask queue drains fully between every two macrotasks
- Mention render step (browsers) happens between macrotasks
Tricky questions they will ask
Q.What happens if a microtask schedules another microtask?›
A.Both run before the next macrotask. Microtasks added during microtask processing are appended to the SAME microtask queue and drained in the same cycle. This is how you can starve the macrotask queue if you keep recursing.
Q.Does `await x` always pause?›
A.Yes. Even if x is a resolved value (not a promise), JS wraps it in a microtask and continues. So await ALWAYS yields at least once.
Free tools you can use while you learn
Common questions
Q.What is the difference between microtasks and macrotasks?›
Q.Why does setTimeout(fn, 0) not run immediately?›
Watching quietly. Tap me if you want a tip.
Try this (0 of 2 done)
- 1
The correct order is A D C B. Confirm by running the snippet.
hint
Microtasks (Promise.then) drain before the next macrotask (setTimeout).
show answer
console.log('A'); setTimeout(() => console.log('B'), 0); Promise.resolve().then(() => console.log('C')); console.log('D'); - 2
Queue a microtask explicitly with queueMicrotask and prove it runs before a setTimeout(fn,0).
show answer
console.log('start'); setTimeout(() => console.log('timeout'), 0); queueMicrotask(() => console.log('microtask')); console.log('end');