But first, JavaScript is single-threaded
JavaScript has only one Call Stack. That means it can only do one thing at a time. If a function is running, nothing else can run until that function is done.
But then think about it — if JS can only do one thing at a time, how does it handle things like API calls, timers, or file reads without freezing the entire page?
That’s where the browser (or Node.js) comes in. The browser provides something called Web APIs — these are separate threads that handle the heavy work outside of JavaScript. Things like setTimeout, fetch, addEventListener are not part of JavaScript itself, they are provided by the browser.
The complete picture
When we write async code, it goes through a cycle. Let’s understand each part:
- Call Stack — Where our code actually runs. Functions get pushed on top when called, and popped off when done.
- Web APIs — The browser handles timers, network requests, DOM events here. This runs in a separate thread.
- Callback Queue (Macrotask Queue) — When a Web API is done (like a timer finishes), the callback is pushed here.
- Microtask Queue — Where Promise callbacks (
.then,.catch,.finally) andqueueMicrotaskgo. This has higher priority than the Callback Queue. - Event Loop — A loop that keeps checking: “Is the Call Stack empty? If yes, pick tasks from the queues and push them to the stack.”
How the Event Loop actually works
The Event Loop follows a simple rule that keeps repeating:
- Run everything in the Call Stack until it’s empty
- Check the Microtask Queue — run ALL of them (drain it completely)
- Pick ONE task from the Callback Queue and push it to the Call Stack
- Go back to step 1
The important thing to remember is — all microtasks are drained before the next macrotask. This is why Promises always run before setTimeout.
Macrotasks vs Microtasks
• queueMicrotask()
• MutationObserver
• async/await (after await)
• setImmediate (Node.js)
• I/O operations
• UI rendering
Example 1: The classic interview question
console.log("1"); // Synchronous — runs first
setTimeout(() => {
console.log("2"); // Callback Queue (macrotask)
}, 0);
Promise.resolve().then(() => {
console.log("3"); // Microtask Queue (higher priority)
});
console.log("4"); // Synchronous — runs first
// Output: 1, 4, 3, 2
Let’s walk through what happens step by step:
console.log("1")
→ Call Stack → runs → prints 1
setTimeout(cb, 0)
→ Call Stack → sent to Web API → timer done → cb goes to Callback Queue
Promise.then(cb)
→ Call Stack → resolved → cb goes to Microtask Queue
console.log("4")
→ Call Stack → runs → prints 4
Even though setTimeout is set to 0ms, the Promise callback runs before it because the Microtask Queue has higher priority than the Callback Queue.
Example 2: Nested microtasks
This is a tricky one. When a microtask creates another microtask, the Event Loop drains ALL of them before moving to the next macrotask.
setTimeout(() => console.log("1"), 0);
Promise.resolve().then(() => {
console.log("2");
Promise.resolve().then(() => console.log("3"));
});
console.log("4");
// Output: 4, 2, 3, 1
Why this order? 4 is synchronous so it runs first. Then the Event Loop picks microtask 2. While running 2, a new microtask 3 is created — the Event Loop drains that too before touching the Callback Queue. Finally, macrotask 1 runs.
Example 3: async/await is just Promises
A lot of people get confused by async/await, but it’s just syntactic sugar over Promises. Everything after await goes to the Microtask Queue.
async function foo() {
console.log("1"); // runs immediately (synchronous)
await Promise.resolve();
console.log("2"); // this goes to Microtask Queue
}
foo();
console.log("3");
// Output: 1, 3, 2
In simple language, when JavaScript sees await, it pauses that function and goes back to run the rest of the code. The paused part resumes later from the Microtask Queue.
Common gotcha: setTimeout(fn, 0) is NOT immediate
A lot of people think setTimeout(fn, 0) means “run immediately”. But it doesn’t. It means “run this as soon as possible after the Call Stack is empty and all microtasks are done”. The 0ms is the minimum delay, not a guarantee.
console.log("start");
setTimeout(() => {
console.log("timeout"); // this will ALWAYS run last
}, 0);
Promise.resolve().then(() => console.log("promise"));
console.log("end");
// Output: start, end, promise, timeout
No matter what, setTimeout(fn, 0) will always wait for all synchronous code and all microtasks to finish first.