Gyaan

Event Loop

advanced async event-loop runtime

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) and queueMicrotask go. 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.”
Call Stack
one thing at a time
↓ async calls go to
Web APIs
setTimeout, fetch, DOM
runs in browser threads
↓ when done, callbacks go to
Microtask Queue high priority
Promises, queueMicrotask
Callback Queue
setTimeout, setInterval
↑ Event Loop moves them to Call Stack

How the Event Loop actually works

The Event Loop follows a simple rule that keeps repeating:

  1. Run everything in the Call Stack until it’s empty
  2. Check the Microtask Queue — run ALL of them (drain it completely)
  3. Pick ONE task from the Callback Queue and push it to the Call Stack
  4. 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

Microtasks (high priority)
• Promise.then / catch / finally
• queueMicrotask()
• MutationObserver
• async/await (after await)
ALL run before next macrotask
Macrotasks (normal priority)
• setTimeout / setInterval
• setImmediate (Node.js)
• I/O operations
• UI rendering
ONE picked per Event Loop cycle

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:

Step 1 console.log("1") → Call Stack → runs → prints 1
Step 2 setTimeout(cb, 0) → Call Stack → sent to Web API → timer done → cb goes to Callback Queue
Step 3 Promise.then(cb) → Call Stack → resolved → cb goes to Microtask Queue
Step 4 console.log("4") → Call Stack → runs → prints 4
Step 5 Call Stack is empty → Event Loop drains Microtask Queue → prints 3
Step 6 Microtask Queue empty → Event Loop picks from Callback Queue → prints 2
Output: 1432

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.

References