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:
-
Call Stack — Currently executing code. One frame at a time.
-
Macrotask Queue — Also called Callback Queue or Task Queue.
-
Microtask Queue — Also called Job Queue.
1. Call Stack
The call stack contains the currently executing code.
Example:
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:
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:
Promise.resolve().then(() => { console.log("Promise"); });
This callback goes into the microtask queue.
The Most Important Rule
The event loop works like this:
-
Execute one task / macrotask
-
Drain ALL microtask
-
Take next macrotask
-
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:
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:
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:
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 2was created after the drain began, it was still processed before thesetTimeout. 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:
MAIN SCRIPT TASK
Only after the main script completes does the event loop start processing queues.
Example 1 — Promise vs setTimeout
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:
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:
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:
start \ \ \
end \ \ \
promise
Step 7 — Microtask queue is now empty. Event loop picks the next macrotask: timeout
Final output:
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
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:
-
Aenters macrotask queue -
Benters microtask queue -
Centers microtask queue -
Denters 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:
Awas 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:
B ← microtask \ \ \
C ← microtask \ \ \
A ← macrotask \ \ \
D ← macrotask
The Confusing Scenario
Now let's examine a situation that confuses many people.
setTimeout(() => { \ \ \
console.log("timeout 1");
Promise.resolve().then(() => { \ \ \
console.log("microtask inside timeout 1"); \ \ \
}); \ \ \
}, 0);
setTimeout(() => { \ \ \
console.log("timeout 2"); \ \ \
}, 0);
Many developers expect:
timeout 1 \ \ \
timeout 2 \ \ \
microtask inside timeout 1
But the actual output is:
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:
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:
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
setTimeoutcallback 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.