The JavaScript Event Loop Explained Simply
Understanding the event loop makes you a better developer.
The Big Picture
┌───────────────────────────────────┐
│ │
│ ┌───────┐ │
│ │ Call │ Your code runs here │
│ │ Stack │ (one thing at a time)│
│ └───┬───┘ │
│ │ │
│ ▼ │
│ ┌───────────┐ │
│ │ Web APIs │ ← setTimeout, │
│ │ │ fetch, DOM │
│ └─────┬─────┘ │
│ │ │
│ ▼ │
│ ┌───────────┐ │
│ │ Callback │ Waiting to run │
│ │ Queue │ │
│ └─────┬─────┘ │
│ │ │
│ ▼ (when stack is empty) │
│ ┌───────────┐ │
│ │ Event Loop│ ← Picks next │
│ │ │ callback │
│ └───────────┘ │
│ │
└───────────────────────────────────┘
How It Works (Step by Step)
console.log('1');
setTimeout(() => {
console.log('2');
}, 0);
Promise.resolve().then(() => {
console.log('3');
});
console.log('4');
// Output: 1, 4, 3, 2
// NOT: 1, 2, 3, 4!
Timeline:
1. console.log('1') → runs immediately → "1"
2. setTimeout(fn, 0) → sends callback to Web API → timer starts
3. Promise.resolve().then(fn) → puts microtask in microtask queue
4. console.log('4') → runs immediately → "4"
5. Call stack empty! Event loop checks:
a. Microtask queue first → "3" (Promise callbacks are microtasks!)
6. Check macrotask queue → "2" (setTimeout is macrotask)
Microtasks vs Macrotasks
// Microtasks (higher priority — always run first):
// - Promise.then/catch/finally
// - MutationObserver
// - queueMicrotask()
// Macrotasks (lower priority):
// - setTimeout/setInterval
// - setImmediate (Node.js)
// - I/O operations
// - UI rendering
// Example:
console.log('start');
setTimeout(() => console.log('setTimeout'), 0);
Promise.resolve()
.then(() => console.log('promise 1'))
.then(() => console.log('promise 2'));
queueMicrotask(() => console.log('microtask'));
setTimeout(() => console.log('setTimeout 2'), 0);
// Output:
// start
// promise 1
// microtask ← microtasks run before any macrotask
// promise 2 ← chained microtasks run together
// setTimeout ← now macrotasks
// setTimeout 2
Practical Implications
Why setTimeout(fn, 0) Isn't Instant
// Even with 0ms delay, it waits for:
// 1. Current synchronous code to finish
// 2. All pending microtasks to complete
// 3. Its turn in the macrotask queue
console.log('A');
setTimeout(() => console.log('B'), 0);
for (let i = 0; i < 100000000; i++) {} // Blocking operation!
console.log('C');
// Output: A, C, B (not A, B, C!)
// The for-loop blocks everything until it finishes
Non-Blocking I/O (Why Node.js Is Fast)
// Node.js doesn't wait for I/O — it continues executing!
const fs = require('fs');
console.log('Reading file...');
fs.readFile('/path/to/file.txt', 'utf8', (err, data) => {
// This callback runs LATER when file is ready
console.log('File content:', data.length, 'bytes');
});
console.log('This logs BEFORE the file finishes reading!');
// Because readFile is non-blocking — Node moves on immediately
Starving the Event Loop
// ❌ This blocks the entire server!
app.get('/heavy', (req, res) => {
const result = computeHeavyThing(10000000); // Synchronous blocking!
res.json({ result });
});
// While this runs, NO other request can be processed!
// ✅ Break up heavy work:
app.get('/heavy', async (req, res) => {
const result = await computeInChunks(10000000); // Yields control periodically
res.json({ result });
});
function computeInChunks(n) {
return new Promise(resolve => {
let result = 0;
let i = 0;
function chunk() {
const end = Math.min(i + 100000, n);
for (; i < end; i++) {
result += Math.sqrt(i);
}
if (i < n) {
// Yield to event loop, continue later
setImmediate(chunk);
} else {
resolve(result);
}
}
chunk();
});
}
requestAnimationFrame vs setTimeout for Animation
// ❌ setTimeout for animation (runs regardless of browser state)
setInterval(() => {
updatePosition();
}, 16); // ~60fps but not synced with display refresh
// ✅ requestAnimationFrame (syncs with display, pauses when tab hidden)
function animate() {
updatePosition();
requestAnimationFrame(animate); // Schedule next frame
}
requestAnimationFrame(animate);
// Benefits:
// - Runs at optimal time (usually 60fps)
// - Pauses when tab is not visible (saves battery!)
// - Synced with GPU/compositor
Debugging Async Code
// Use this pattern to trace execution order:
const log = (msg) => console.log(`${new Date().toISOString().slice(11,19)} ${msg}`);
log('Start');
setTimeout(() => log('Timeout'), 100);
Promise.resolve().then(() => log('Promise'));
log('End');
// Output with timestamps shows exact order:
// 12:34:56.789 Start
// 12:34:56.790 End
// 12:34:56.791 Promise ← microtask, almost instant
// 12:34:57.790 Timeout ← macrotask, after ~1000ms
Quick Reference
| Concept | Description |
|---|---|
| Call Stack | Where your code executes (LIFO) |
| Web APIs | Browser/Node features (setTimeout, fetch, etc.) |
| Callback Queue | Where completed async tasks wait |
| Event Loop | Moves callbacks from queue to stack when empty |
| Microtask Queue | Higher priority (Promises, MutationObserver) |
| Macrotask Queue | Lower priority (setTimeout, setInterval) |
| Blocking | Long sync code prevents anything else from running |
| Non-blocking | Async code yields control while waiting |
Did this help you understand the event loop? Any questions?
Follow @armorbreak for more JS fundamentals.
Top comments (0)