The JavaScript Event Loop Explained Simply
Why does setTimeout sometimes take longer than expected? The event loop holds the answer.
The Big Picture
┌──────────────────────────────┐
│ Call Stack │ ← Your code runs here (LIFO)
│ (function executions) │
├──────────────────────────────┤
│ Web APIs (Browser) │ ← setTimeout, fetch, DOM...
│ / C++ APIs (Node.js) │
├──────────────────────────────┤
│ Task Queue │ ← Callbacks wait here
│ (setTimeout, I/O, etc.) │
├──────────────────────────────┤
│ Microtask Queue │ ← Promises, queueMicrotask
│ (Promise.then/catch/finally)│
└──────────────────────────────┘
↕ Event Loop (moves tasks between queues)
How It Works — Step by Step
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');
What gets logged?
1 → 4 → 3 → 2
Why?
1. console.log('1') executes immediately on call stack → Output: "1"
2. setTimeout schedules callback in Task Queue (macrotask)
3. Promise.resolve().then() schedules callback in Microtask Queue
4. console.log('4') executes immediately → Output: "4"
5. Call stack is empty → Event Loop checks Microtask Queue first!
6. Promise callback runs → Output: "3"
7. Microtask Queue empty → Event Loop checks Task Queue
8. setTimeout callback runs → Output: "2"
The Golden Rule
MICROTASKS ALWAYS RUN BEFORE MACROTASKS
Microtasks: Promise.then/catch/finally, queueMicrotask, MutationObserver
Macrotasks: setTimeout, setInterval, setImmediate, I/O, UI rendering
// Proof:
console.log('Start');
setTimeout(() => console.log('Timeout'), 0);
Promise.resolve()
.then(() => console.log('Promise 1'))
.then(() => console.log('Promise 2'))
.then(() => console.log('Promise 3'));
// Output:
// Start
// Promise 1
// Promise 2
// Promise 3
// Timeout
// ALL microtasks run before the next macrotask!
// Even if you chain 100 .then() calls, they ALL run before setTimeout.
Why setTimeout(fn, 0) Isn't Instant
const start = Date.now();
setTimeout(() => {
console.log(`Timeout after ${Date.now() - start}ms`);
}, 0);
// Block the call stack for 100ms
let i = 0;
while (Date.now() - start < 100) {
i++;
}
console.log(`Loop done after ${Date.now() - start}ms`);
// Output:
// Loop done after 100ms
// Timeout after ~102ms (not 0ms!)
The minimum delay is never 0ms:
- Browser: minimum 4ms (after 5th nested timer)
- Node.js: minimum 1ms
- Plus: whatever time the call stack is busy
Real-World Impact
Problem 1: Starving the UI
// ❌ Blocking the main thread
function heavyComputation() {
const result = [];
for (let i = 0; i < 10_000_000; i++) {
result.push(Math.sqrt(i));
}
return result;
}
heavyComputation(); // Page freezes for seconds!
// ✅ Yield to the event loop periodically
async function nonBlockingComputation() {
const result = [];
for (let i = 0; i < 10_000_000; i++) {
result.push(Math.sqrt(i));
// Every 100K iterations, yield to let other code run
if (i % 100_000 === 0) {
await new Promise(resolve => setTimeout(resolve, 0));
}
}
return result;
}
Problem 2: Race Conditions
let data;
fetch('/api/data')
.then(res => res.json())
.then(json => { data = json; });
console.log(data); // undefined! fetch hasn't completed yet!
// ✅ Use async/await
async function getData() {
const res = await fetch('/api/data');
return await res.json();
}
const data = await getData(); // Waits for completion
console.log(data); // Works!
Problem 3: Microtask Starvation
// DANGEROUS: Infinite microtask loop freezes everything!
function infiniteMicrotasks() {
Promise.resolve().then(infiniteMicrotasks);
}
infiniteMicrotasks();
// No rendering, no input handling, no timers — page completely frozen!
// Because microtasks run until EMPTY before anything else.
requestAnimationFrame — For Visual Updates
// Runs before browser's next paint (usually 60fps)
function animate() {
element.style.left = `${x}px`;
x += 1;
if (x < 500) {
requestAnimationFrame(animate);
}
}
requestAnimationFrame(animate);
// Order of execution per frame:
// 1. Run requestAnimationFrame callbacks
// 2. Run IntersectionObserver callbacks
// 3. Render/paint the DOM
// 4. Check Task Queue for new macrotasks
Node.js Specifics
// Node.js has additional phases in its event loop:
timers → pending callbacks → idle/prepare → poll → check → close callbacks
// setImmediate (Node.js only) — runs in "check" phase
// setTimeout — runs in "timers" phase
// setImmediate runs BEFORE setTimeout(0) in Node.js!
setImmediate(() => console.log('immediate'));
setTimeout(() => console.log('timeout'), 0);
// In Node.js output: immediate → timeout
// In browser: timeout → immediate (no setImmediate)
Quick Reference
| Type | Examples | Priority |
|---|---|---|
| Synchronous |
console.log, calculations |
Highest (runs now) |
| Microtask |
.then, queueMicrotask
|
High (before render/macrotask) |
| requestAnimationFrame | rAF callbacks | Before paint |
| Macrotask |
setTimeout, setInterval, I/O |
Lowest (after microtasks) |
Did this change how you think about async JS?
Follow @armorbreak for more JavaScript content.
Top comments (0)