The JavaScript Event Loop¶
How JavaScript handles asynchronous operations.
Single-Threaded, Non-Blocking¶
JavaScript runs on a single thread, but handles async operations without blocking.
┌─────────────────────────────────────────────────────┐
│ JavaScript │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Call │ │ Event │ │ Callback │ │
│ │ Stack │ │ Loop │ │ Queue │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
└─────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────┐
│ Web APIs / Node APIs │
│ (setTimeout, fetch, fs, etc. - run outside JS) │
└─────────────────────────────────────────────────────┘
Core Components¶
Call Stack¶
Tracks function execution. LIFO (Last In, First Out).
function first() {
second();
}
function second() {
third();
}
function third() {
console.log('Hello');
}
first();
// Stack progression:
// 1. first()
// 2. first() → second()
// 3. first() → second() → third()
// 4. first() → second() → third() → console.log()
// 5. first() → second() → third()
// 6. first() → second()
// 7. first()
// 8. (empty)
Event Loop¶
Continuously checks: "Is the call stack empty? Are there callbacks waiting?"
while (true) {
if (callStack.isEmpty()) {
// 1. Process all microtasks (Promises)
while (microtaskQueue.hasItems()) {
const microtask = microtaskQueue.dequeue();
execute(microtask);
}
// 2. Process one macrotask (setTimeout, I/O)
if (macrotaskQueue.hasItems()) {
const macrotask = macrotaskQueue.dequeue();
execute(macrotask);
}
// 3. Render if needed (browser only)
if (shouldRender()) {
render();
}
}
}
Task Queues¶
Microtasks (higher priority): - Promise callbacks (.then, .catch, .finally) - queueMicrotask() - MutationObserver
Macrotasks (lower priority): - setTimeout / setInterval - I/O callbacks - UI rendering - setImmediate (Node.js)
Execution Order¶
console.log('1 - Sync');
setTimeout(() => console.log('2 - setTimeout'), 0);
Promise.resolve().then(() => console.log('3 - Promise'));
queueMicrotask(() => console.log('4 - Microtask'));
console.log('5 - Sync');
// Output:
// 1 - Sync
// 5 - Sync
// 3 - Promise
// 4 - Microtask
// 2 - setTimeout
Why this order? 1. Sync code runs first (1, 5) 2. Microtasks run before macrotasks (3, 4) 3. setTimeout is a macrotask (2)
Detailed Example¶
console.log('Start');
setTimeout(() => {
console.log('setTimeout 1');
Promise.resolve().then(() => console.log('Promise inside setTimeout'));
}, 0);
setTimeout(() => {
console.log('setTimeout 2');
}, 0);
Promise.resolve()
.then(() => {
console.log('Promise 1');
return Promise.resolve();
})
.then(() => console.log('Promise 2'));
console.log('End');
// Output:
// Start
// End
// Promise 1
// Promise 2
// setTimeout 1
// Promise inside setTimeout
// setTimeout 2
Explanation:
1. Start and End (synchronous)
2. Promise 1 (microtask)
3. Promise 2 (microtask from Promise 1)
4. setTimeout 1 (macrotask)
5. Promise inside setTimeout (microtask from setTimeout 1)
6. setTimeout 2 (next macrotask)
Blocking the Event Loop¶
// BAD: Blocks the main thread
function heavyComputation() {
let sum = 0;
for (let i = 0; i < 1e9; i++) {
sum += i;
}
return sum;
}
// This blocks everything for seconds!
heavyComputation();
console.log('This waits...');
Solution: Chunking¶
// GOOD: Process in chunks, yield to event loop
async function heavyComputationChunked() {
let sum = 0;
const chunkSize = 1e6;
for (let i = 0; i < 1e9; i += chunkSize) {
// Process chunk
const end = Math.min(i + chunkSize, 1e9);
for (let j = i; j < end; j++) {
sum += j;
}
// Yield to event loop
await new Promise(resolve => setTimeout(resolve, 0));
}
return sum;
}
Solution: Web Worker¶
// main.js
const worker = new Worker('worker.js');
worker.postMessage({ start: 0, end: 1e9 });
worker.onmessage = (e) => console.log('Result:', e.data);
// worker.js
self.onmessage = (e) => {
let sum = 0;
for (let i = e.data.start; i < e.data.end; i++) {
sum += i;
}
self.postMessage(sum);
};
requestAnimationFrame¶
Runs before the next repaint, ideal for animations.
function animate() {
// Update animation
element.style.left = position + 'px';
position += 1;
// Schedule next frame
requestAnimationFrame(animate);
}
requestAnimationFrame(animate);
Timing in event loop:
Node.js Specifics¶
Node.js has additional phases:
┌───────────────────────────┐
┌─▶│ timers │ ← setTimeout, setInterval
│ └─────────────┬─────────────┘
│ ┌─────────────▼─────────────┐
│ │ pending callbacks │ ← I/O callbacks
│ └─────────────┬─────────────┘
│ ┌─────────────▼─────────────┐
│ │ idle, prepare │
│ └─────────────┬─────────────┘
│ ┌─────────────▼─────────────┐
│ │ poll │ ← incoming connections, data
│ └─────────────┬─────────────┘
│ ┌─────────────▼─────────────┐
│ │ check │ ← setImmediate
│ └─────────────┬─────────────┘
│ ┌─────────────▼─────────────┐
└──│ close callbacks │ ← socket.on('close')
└───────────────────────────┘
setImmediate vs setTimeout¶
// Order can vary
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
// Inside I/O callback, setImmediate always first
fs.readFile('file.txt', () => {
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
// Output: immediate, timeout
});
process.nextTick¶
Higher priority than microtasks in Node.js.
Promise.resolve().then(() => console.log('promise'));
process.nextTick(() => console.log('nextTick'));
// Output: nextTick, promise
Common Pitfalls¶
Starving the Event Loop¶
// BAD: Microtasks can starve macrotasks
function recursivePromise() {
Promise.resolve().then(recursivePromise);
}
recursivePromise();
// setTimeout callbacks never run!
Assuming setTimeout(0) is Immediate¶
// setTimeout has minimum delay (~4ms in browsers)
const start = Date.now();
setTimeout(() => {
console.log(Date.now() - start); // Often 4-5ms, not 0
}, 0);
Not Handling Rejections¶
// BAD: Unhandled rejection
Promise.reject(new Error('Oops'));
// GOOD: Always handle
Promise.reject(new Error('Oops')).catch(console.error);
// Or use try/catch with async/await
async function safe() {
try {
await riskyOperation();
} catch (error) {
console.error(error);
}
}
Debugging¶
Performance Timeline¶
// Mark events for Performance tab
performance.mark('start');
await operation();
performance.mark('end');
performance.measure('Operation', 'start', 'end');
Long Task Detection¶
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log('Long task:', entry.duration);
}
});
observer.observe({ entryTypes: ['longtask'] });
Best Practices¶
- Don't block the main thread — Keep tasks < 50ms
- Use microtasks wisely — Don't create infinite chains
- Chunk heavy work — Yield periodically
- Use Web Workers — For CPU-intensive tasks
- Understand the order — Microtasks before macrotasks
- Profile performance — Use browser dev tools