The JavaScript Event Loop Explained Simply
Why does setTimeout(callback, 0) not run immediately? Let me explain.
The Core Concept
JavaScript is single-threaded.
It can only do ONE thing at a time.
But wait — how can it handle:
- Click events while fetching data?
- Animations while processing user input?
- Multiple timers at once?
Answer: THE EVENT LOOP
How It Works (Visual)
┌───────────────────────────────────┐
│ Call Stack │ ← Code runs here (LIFO)
│ ┌─────┐ │
│ │ log │ ← Currently executing │
│ └─────┘ │
│ ┌─────┐ │
│ │ main│ ← Waiting to run │
│ └─────┘ │
└──────────┬────────────────────────┘
│
▼
┌───────────────────────────────────┐
│ Web APIs │ ← Browser handles async ops
│ setTimeout, fetch, DOM, ... │
└──────────┬────────────────────────┘
│ (when complete)
▼
┌───────────────────────────────────┐
│ Callback Queue │ ← Waiting their turn
│ [click] [timer] [fetch resolve] │
└──────────┬────────────────────────┘
│ (when stack is empty)
▼
Event Loop picks up the next callback
Step-by-Step Example
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');
What gets logged? Let's trace:
1. console.log('1') → runs immediately → output: "1"
2. setTimeout(cb, 0) → timer registered in Web APIs
3. Promise.then(cb) → microtask queued
4. console.log('4') → runs immediately → output: "1, 4"
Call Stack is now EMPTY.
Event Loop checks:
1. Microtask Queue: has Promise callback → runs it → "3"
2. Microtask Queue: empty now
3. Task (Callback) Queue: has setTimeout → runs it → "2"
Final output: 1, 4, 3, 2
NOT 1, 2, 3, 4!
NOT 1, 4, 2, 3!
The Promise (microtask) ran BEFORE setTimeout (macrotask)!
Microtasks vs Macrotasks
Priority Order (when stack is empty):
1. All Microtasks (run ALL of them before anything else)
- Promise.then/catch/finally
- queueMicrotask()
- MutationObserver
2. One Macrotask (just ONE)
- setTimeout/setInterval
- I/O callbacks
- UI rendering
- requestAnimationFrame
3. Render (if needed)
- Style calculation
- Layout
- Paint
4. Back to step 1 (check microtasks again!)
// Proof of microtask priority:
console.log('start');
setTimeout(() => console.log('timeout'), 0);
Promise.resolve()
.then(() => console.log('promise 1'))
.then(() => console.log('promise 2'));
Promise.resolve().then(() => console.log('promise 3'));
console.log('end');
// Output: start, end, promise 1, promise 3, promise 2, timeout
//
// Explanation:
// 1. sync: start, end
// 2. microtasks: promise 1, promise 3, promise 2 (all of them!)
// 3. macrotask: timeout (only after all microtasks done)
Real-World Impact
Why Your UI Freezes
// ❌ Blocking the call stack
function heavy() {
const start = Date.now();
while (Date.now() - start < 3000) {
// CPU-bound work for 3 seconds
// During this time: NO clicks, NO animations, NOTHING
}
}
heavy(); // Page is frozen for 3 seconds!
// ✅ Break it into chunks using setTimeout(0)
function heavyNonBlocking(chunks = 10) {
let i = 0;
function processChunk() {
const start = Date.now();
while (Date.now() - start < 50 && i < chunks) { // Max 50ms per chunk
processChunkData(i);
i++;
}
if (i < chunks) {
setTimeout(processChunk, 0); // Yield to event loop between chunks
}
}
processChunk();
}
Why requestAnimationFrame Exists
// ❌ setInterval for animation (runs regardless of render cycle)
setInterval(updatePosition, 16); // ~60fps but not synced to screen refresh
// ✅ requestAnimationFrame (syncs to browser's paint cycle)
function animate() {
updatePosition();
requestAnimationFrame(animate); // Called right before next paint
}
requestAnimationFrame(animate);
// Runs at optimal rate for the display
// Pauses when tab is backgrounded (saves battery!)
// Automatically adjusts for monitor refresh rate
Why async/await Doesn't Block
async function loadData() {
console.log('before await'); // Sync — runs on call stack
const data = await fetch(url); // Yields control here!
// Fetch happens in Web APIs
// Rest of function becomes a microtask
console.log('after await'); // This is a microtask callback
return data;
}
console.log('calling loadData'); // Sync
loadData(); // Starts executing, hits await, returns
console.log('after calling'); // Sync — continues immediately
// Output:
// calling loadData
// before await
// after calling
// after await (after fetch completes)
Common Interview Questions
Q1: What's the output?
for (var i = 0; i < 5; i++) {
setTimeout(() => console.log(i), 100);
}
// Answer: 5, 5, 5, 5, 5 (var is function-scoped, loop finishes first)
// Fix 1: let (block-scoped)
for (let i = 0; i < 5; i++) {
setTimeout(() => console.log(i), 100);
}
// Answer: 0, 1, 2, 3, 4
// Fix 2: IIFE (closure)
for (var i = 0; i < 5; i++) {
((j) => setTimeout(() => console.log(j), 100))(i);
}
// Answer: 0, 1, 2, 3, 4
Q2: What's the output?
async function async1() {
console.log('async1 start');
await async2();
console.log('async1 end');
}
async function async2() {
console.log('async2');
}
console.log('script start');
setTimeout(() => console.log('setTimeout'), 0);
async1();
new Promise(resolve => {
console.log('promise1');
resolve();
}).then(() => console.log('promise2'));
console.log('script end');
// Answer:
// script start
// async1 start
// async2 (await yields, then sync part of async2 runs)
// promise1
// script end
// async1 end (microtask from await)
// promise2 (microtask from .then())
// setTimeout (macrotask)
Quick Reference
| Task | Where It Runs |
|---|---|
| Synchronous code | Call Stack |
| DOM events | Task Queue (macrotask) |
| setTimeout/setInterval | Task Queue (macrotask) |
| fetch/XHR callbacks | Microtask Queue |
| Promise .then/.catch | Microtask Queue |
| queueMicrotask() | Microtask Queue |
| requestAnimationFrame | Before paint (not exactly a queue) |
Does the event loop finally make sense?
Follow @armorbreak for more JavaScript content.
Top comments (0)