Mastering the JavaScript Event Loop - Microtasks vs Macrotasks (with example)
Quick Recap!! Understand how the event loop schedules work, why promises run before setTimeout callbacks, and see a corrected example that demonstrates the exact output order.

When I first built a tiny chat widget for a client, messages sometimes appeared in the wrong order. The UI would show a system message before the user’s own message — even though the network response arrived later. I spent an afternoon adding logs, stepping through code, and finally discovered the culprit: misunderstanding when async callbacks run. That day I learned that JavaScript’s event loop decides “when” things run — and knowing microtasks vs macrotasks turned a confusing bug into a predictable system.
What is Event Loop?
JavaScript runs code on a single thread, but it’s non-blocking because asynchronous operations are handled outside the call stack (in browser/Node APIs). The event loop coordinates when those asynchronous results are moved back to the call stack.
Synchronous code runs immediately on the call stack.
Asynchronous tasks (timers, network, etc.) are handled by platform APIs and, when ready, are queued.
Key runtime parts:
Call stack — where functions run right now.
Web APIs (timers, network, DOM, etc.) — handle async operations.
Task queues:
Macrotask queue (tasks): setTimeout, setInterval, I/O callbacks, setImmediate (Node), UI rendering frames.
Microtask queue (jobs): Promise callbacks (.then/.catch/.finally), queueMicrotask, MutationObserver.
Event loop — picks work: run next call stack, then process microtasks, then render, then take next macrotask.
Short rule of thumb: when the call stack empties, microtasks run first (draining the microtask queue), then the browser may repaint, then the next macrotask executes.
function runTask(){
console.log("Good morning");
setTimeout(() => {
console.log("Run 2 after 4 sec");
}, 4000);
setTimeout(() => {
console.log("Run 3 after 2 sec");
}, 2000);
console.log("Good Evening, then Promise");
Promise.resolve().then(() => {
console.log("Promise");
});
runTask()
// Good morning
// Good Evening, Then Promise
// Promise
// Run 3 after 2 sec
// Run 2 after 4 sec
Why this happens:
The two
console.logcalls are synchronous and run first.The
Promisecallback is a microtask; microtasks run immediately after the call stack is empty, before any macrotasks.The
setTimeoutcallbacks are macrotasks scheduled after their delays; the one with 2000ms runs before the 4000ms one.
Normally, code runs line by line, but the event loop makes it run differently. It executes tasks that finish first. In the example, "Good morning" is printed, then "Good Evening, then Promise" followed by "Run 3 after 2 sec," and finally "Run 2 after 4 sec." This shows why JavaScript is non-blocking.
Here's how it works: when the code runs, JavaScript checks if a task is asynchronous. If it is, it moves the task from the call stack to the browser's web APIs, where delays happen. Once a task is done in web APIs, it's sent either to the microtask queue (for promises) or the macrotask queue (for setTimeout). Microtasks have higher priority. While the synchronous code runs, asynchronous tasks wait in line. The event loop, like a traffic officer, checks if the call stack is empty. If it is, tasks are moved from the microtask queue or macrotask queue to the call stack, allowing important operations to continue without blocking.
Let's explore a bit and walk through a simple example.
console.log("Good morning"); // 1: sync
console.log("Sync log 2"); // 2: sync
setTimeout(() => console.log("Run after 2 sec"), 2000); // macrotask (2s)
setTimeout(() => console.log("Run after 4 sec"), 4000); // macrotask (4s)
Promise.resolve().then(() => console.log("Good Evening, then Promise")); // microtask
// Output order:
// 1. Good morning
// 2. Sync log 2
// 3. Good Evening, then Promise <-- microtask runs after sync code, before macrotasks
// 4. Run after 2 sec
// 5. Run after 4 sec
Step-by-step:
Top-level synchronous logs run immediately on the call stack.
setTimeout callbacks are registered with the browser/Node timers (they become macrotasks when their timers expire).
Promise.resolve().then(...) creates a microtask — queued to run as soon as the current synchronous code finishes.
When the main script finishes, the event loop drains the microtask queue (so the Promise callback runs) before any macrotask callbacks (like setTimeout) run — even if a macrotask had zero delay.
Timer callbacks run when their delay expires and after microtasks and rendering happen.
Mini diagram (text):
Call stack -> run sync code -> script ends -> drain microtask queue (Promise callbacks) -> render / paint (browser may do this) -> run next macrotask (setTimeout, I/O) -> repeat
Microtask vs Macrotask — quick summary
Microtasks
Examples:
Promise.then/catch/finally,queueMicrotask,MutationObserverHighest priority after current synchronous code
Executed immediately when the call stack is empty (before rendering and before macrotasks)
Macrotasks
Examples:
setTimeout,setInterval, I/O callbacks, UI events,setImmediate(Node)Lower priority than microtasks; processed one macrotask at a time
Promise vs setTimeout:
Promise callbacks use microtasks. They will run immediately after current code completes.
setTimeout(…, 0) is still a macrotask — it runs after microtasks, not instantly.
Example showing async/await behaves like Promise.then:
async function demo() {
console.log("start");
await Promise.resolve();
console.log("after await (microtask)");
}
demo();
console.log("sync after demo");
// Order:
// start
// sync after demo
// after await (microtask)
Explanation: await pauses the async function and schedules the rest as a microtask. The outer sync log runs before the awaited continuation.
What are timers 🤔?
Timers are part of the Web APIs provided by browsers. They let you schedule code to run after a certain delay or repeatedly at a fixed interval. Timers are commonly used for timeouts, polling, and simple animations (though for animation requestAnimationFrame is usually better).
Common timer functions (Web APIs)
setTimeout(fn, delay, ...args)
Schedulesfnto run once afterdelaymilliseconds. Returns a timer id.setInterval(fn, delay, ...args)
Schedulesfnto run repeatedly everydelaymilliseconds. Returns a timer id.clearTimeout(id)
Cancels a timer created bysetTimeout(in browsers it also works with ids returned bysetInterval).clearInterval(id)
Cancels a timer created bysetInterval.
KEY LEARNING
Microtasks (Promises, queueMicrotask) always run before the next macrotask once the call stack is empty.
setTimeout/setInterval callbacks are macrotasks — scheduling order depends on timer delay and queue order.
Long-running synchronous code blocks everything: UI, microtasks, macrotasks — so avoid heavy sync work.
async/await uses Promises under the hood — awaiting yields to microtasks.
Test and log ordering with minimal reproducible examples to avoid hidden timing bugs.
Conclusion
Microtasks (Promises,
queueMicrotask) run right after the current call stack; macrotasks (setTimeout, I/O) run later.Use microtasks for immediate follow-ups and avoid
setTimeout(0)for ordering.Reproduce minimal examples with logs — once you internalize this rule, timing bugs become predictable.



