JavaScriptEzazul Islam5 min read2026-09-29

Understanding the Event Loop

Understanding the Event Loop

Understanding the Event Loop

Callback Queue vs Job Queue (Microtask Queue) — a complete mental model from scratch.


One of the most confusing topics in JavaScript is the event loop — especially when people hear things like: "Microtasks have higher priority than callbacks" or "Promises execute before setTimeout."

These statements are technically correct, but they often create the wrong mental model. The confusion usually disappears once you understand one crucial idea:

The main script itself is also a task (macrotask).

This article builds the complete mental model from scratch.


The Three Important Pieces

JavaScript runtime mainly works with:

  1. Call Stack — Currently executing code. One frame at a time.

  2. Macrotask Queue — Also called Callback Queue or Task Queue.

  3. Microtask Queue — Also called Job Queue.


1. Call Stack

The call stack contains the currently executing code.

Example:

SnippetCODE
 console.log("Hello"); 

console.log goes onto the stack, executes, and gets removed.

JavaScript can execute only one thing at a time because it is single-threaded


2. Macrotask Queue (Callback Queue)

This queue stores tasks like:

  • setTimeout

  • setInterval

  • DOM events

  • I/O callbacks

Example:

SnippetCODE
 setTimeout(() => { console.log("Timeout"); }, 0); 

The callback does NOT execute immediately. It gets placed into the macrotask queue.


3. Microtask Queue (Job Queue)

This queue stores:

  • Promise.then

  • Promise.catch

  • queueMicrotask

  • MutationObserver

Example:

SnippetCODE
 Promise.resolve().then(() => { console.log("Promise"); }); 

This callback goes into the microtask queue.


The Most Important Rule

The event loop works like this:

  1. Execute one task / macrotask

  2. Drain ALL microtask

  3. Take next macrotask

  4. Repeat forever

"Drain" means: keep running microtasks until the queue is completely empty — including any new microtasks queued during that draining pass.

Drain in action

Here is a concrete example showing that a microtask queued inside another microtask is still processed before the next macrotask:

SnippetCODE
 Promise.resolve().then(() => { \ \
 console.log("microtask 1"); \ \
 Promise.resolve().then(() => { \ \
 console.log("microtask 2"); \ \
 }); \ \
 }); 

setTimeout(() => { \ \
 console.log("timeout"); \ \
 }, 0); 

Step-by-Step Execution

Step 1 — Main script starts. Current task: MAIN SCRIPT

Step 2 — Promise.resolve().then(…) — the outer .then callback enters the microtask queue. setTimeout callback is registered and enters the macrotask queue.

  • Macrotask Queue: timeout cb

  • Microtask Queue: microtask 1 cb

Step 3 — Main script finishes. Event loop drains microtasks. It picks microtask 1 cb and runs it. console.log("microtask 1") executes. Then, inside that callback, a new Promise.resolve().then(…) queues microtask 2 cb immediately into the microtask queue.

Output so far:\ \
microtask 1

  • Macrotask Queue: timeout cb

  • Microtask Queue: microtask 2 cb

Step 4 — The drain is not finished yet — microtask 2 cb is still in the queue. The event loop picks it up and runs it. console.log("microtask 2") executes.

Output so far:

SnippetCODE
 microtask 1 \ \ \
 microtask 2 

  • Macrotask Queue: timeout cb

  • Microtask Queue: (empty)

Step 5 — Microtask queue is now truly empty. Only now does the event loop pick the next macrotask: timeout cb. console.log("timeout") executes.

Final output:

SnippetCODE
 microtask 1 ← first microtask, runs after main script \ \ \
 microtask 2 ← queued during the drain, still runs before timeout \ \ \
 timeout ← macrotask, runs only after queue is fully empty 

Even though microtask 2 was created after the drain began, it was still processed before the setTimeout. The drain does not stop after one pass — it keeps going until nothing is left.

The key phrase is: "After the current task finishes."


The Missing Piece Most Beginners Ignore

The ENTIRE script file itself is also a macrotask.

This is the key to understanding everything.

So when your program starts, JavaScript is already executing one task:

SnippetCODE
 MAIN SCRIPT TASK 

Only after the main script completes does the event loop start processing queues.


Example 1 — Promise vs setTimeout

SnippetCODE
 console.log("start"); 

setTimeout(() => { \ \ \
 console.log("timeout"); \ \ \
 }, 0); 

Promise.resolve().then(() => { \ \ \
 console.log("promise"); \ \ \
 }); 

console.log("end"); 

Step-by-Step Execution

Step 1 — Main Script Starts. Current task: MAIN SCRIPT

Step 2 — Execute console.log("start")

Output so far:

SnippetCODE
 start 

Step 3 — setTimeout callback is moved to the macrotask queue.

  • Macrotask Queue: timeout

  • Microtask Queue: (empty)

Step 4 — Promise callback enters the microtask queue.

  • Macrotask Queue: timeout

  • Microtask Queue: promise

Step 5 — Execute console.log("end")

Output so far:

SnippetCODE
 start \ \ \
 end 

Step 6 — Main Script Finishes. Event loop rule activates: drain all microtasks before taking another macrotask. Microtask queue executes: promise

Output so far:

SnippetCODE
 start \ \ \
 end \ \ \
 promise 

Step 7 — Microtask queue is now empty. Event loop picks the next macrotask: timeout

Final output:

SnippetCODE
 start \ \ \
 end \ \ \
 promise ← microtask, runs after main script \ \ \
 timeout ← macrotask, runs last 

Why Promises Execute Before setTimeout

Not because promises "interrupt" JavaScript. Not because promises "run instantly." But because:

Main script finishes → microtasks are drained → next macrotask runs


Example 2 — Multiple Timers and a Promise

SnippetCODE
 setTimeout(() => console.log("A"), 0); 

Promise.resolve().then(() => console.log("B")); 

Promise.resolve().then(() => console.log("C")); 

setTimeout(() => console.log("D"), 0); 

What Happens?

During the main script:

  • A enters macrotask queue

  • B enters microtask queue

  • C enters microtask queue

  • D enters macrotask queue

After main script finishes:

  • Macrotask Queue: A, D

  • Microtask Queue: B, C

Now the event loop drains all microtasks first: B, then C. Then macrotasks: A, then D.

Note: A was queued before B and C, but still prints after them. Macrotasks always wait until all pending microtasks have drained — regardless of when they were queued.

Final output:

SnippetCODE
 B ← microtask \ \ \
 C ← microtask \ \ \
 A ← macrotask \ \ \
 D ← macrotask 


The Confusing Scenario

Now let's examine a situation that confuses many people.

SnippetCODE
 setTimeout(() => { \ \ \
 console.log("timeout 1"); 

Promise.resolve().then(() => { \ \ \
 console.log("microtask inside timeout 1"); \ \ \
 }); \ \ \
 }, 0); 

setTimeout(() => { \ \ \
 console.log("timeout 2"); \ \ \
 }, 0); 

Many developers expect:

SnippetCODE
 timeout 1 \ \ \
 timeout 2 \ \ \
 microtask inside timeout 1 

But the actual output is:

SnippetCODE
 timeout 1 \ \ \
 microtask inside timeout 1 \ \ \
 timeout 2 

Why?

Step-by-Step Breakdown

After the main script:

  • Macrotask Queue: timeout1, timeout2

  • Microtask Queue: (empty)

Event Loop Picks ONE Macrotask

It executes timeout1. Inside it:

  • console.log("timeout 1") executes

  • Promise.resolve().then(…) adds a microtask

Queues now:

  • Macrotask Queue: timeout2

  • Microtask Queue: microtask-inside-timeout1

Critical Moment

Now timeout1 finishes. The event loop rule activates again:

Task finished → drain all microtasks

So the microtask executes before timeout2.

Final output:

SnippetCODE
 timeout 1 \ \ \
 microtask inside timeout 1 ← drained before timeout 2 \ \ \
 timeout 2 


The Correct Mental Model

Many beginners incorrectly imagine this:

Wrong model: callback → callback → callback → then microtasks

But JavaScript actually works like this:

Correct model: ONE macrotask → ALL microtasks → ONE macrotask → ALL microtasks → …

This cycle repeats forever.


Why Microtasks Can "Starve" the Event Loop

Because the runtime must completely empty the microtask queue before moving to the next macrotask.

Example:

SnippetCODE
 function loop() { \ \ \
 Promise.resolve().then(loop); \ \ \
 } 

loop(); 

setTimeout(() => { \ \ \
 console.log("timeout"); \ \ \
 }, 0); 

Here, microtasks continuously create more microtasks. The event loop never gets a chance to process the timer.

⚠️ This is called microtask starvation. The setTimeout callback will never run because the microtask queue never becomes empty.


Final Summary

Macrotasks (Callback Queue):

  • setTimeout

  • setInterval

  • DOM events

  • I/O callbacks

Microtasks (Job Queue):

  • Promise.then

  • Promise.catch

  • queueMicrotask

  • MutationObserver

Golden Rule of the Event Loop

Current task finishes → drain all microtasks → execute next macrotask → repeat

The Most Important Insight

The biggest breakthrough usually comes from understanding this:

The main script itself is also a macrotask.

Once you realize that, event loop behavior becomes much easier to reason about.