Skip to main content

Command Palette

Search for a command to run...

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.

Updated
6 min read
Mastering the JavaScript Event Loop - Microtasks vs Macrotasks (with example)
S
Passionate Full-Stack JavaScript Developer focused on building intuitive, scalable, and user-centric digital experiences using modern technologies.Specialized in the MERN stack (React, Node.js, and MongoDB) with practical experience architecting end-to-end web applications and seamlessly integrating intelligent AI features into modern platforms. I am highly comfortable handling both pixel-perfect frontend interfaces and robust backend logic.Driven by turning complex problems into elegant production-ready code. You can explore my full range of work, architecture patterns, and live deployments through my portfolio and GitHub links featured below.Outside the digital world, I recharge by exploring nature—especially during the monsoon season.

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.log calls are synchronous and run first.

  • The Promise callback is a microtask; microtasks run immediately after the call stack is empty, before any macrotasks.

  • The setTimeout callbacks 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:

  1. Top-level synchronous logs run immediately on the call stack.

  2. setTimeout callbacks are registered with the browser/Node timers (they become macrotasks when their timers expire).

  3. Promise.resolve().then(...) creates a microtask — queued to run as soon as the current synchronous code finishes.

  4. 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.

  5. 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, MutationObserver

    • Highest 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)
    Schedules fn to run once after delay milliseconds. Returns a timer id.

  • setInterval(fn, delay, ...args)
    Schedules fn to run repeatedly every delay milliseconds. Returns a timer id.

  • clearTimeout(id)
    Cancels a timer created by setTimeout (in browsers it also works with ids returned by setInterval).

  • clearInterval(id)
    Cancels a timer created by setInterval.

KEY LEARNING

  1. Microtasks (Promises, queueMicrotask) always run before the next macrotask once the call stack is empty.

  2. setTimeout/setInterval callbacks are macrotasks — scheduling order depends on timer delay and queue order.

  3. Long-running synchronous code blocks everything: UI, microtasks, macrotasks — so avoid heavy sync work.

  4. async/await uses Promises under the hood — awaiting yields to microtasks.

  5. 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.