The JavaScript Event Loop Explained Simply
The event loop is the reason JavaScript can do async work with a single thread. Here's how it actually works.
The Big Picture
┌─────────────────────────────────┐
│ JavaScript Code │
│ (runs on single thread) │
└─────────┬───────────────────────┘
│
┌─────────▼───────────────────────┐
│ Event Loop │
│ │
│ 1. Check Call Stack │
│ 2. If empty → pick from Queue │
│ 3. Execute callback │
│ 4. Repeat │
└─────────────────────────────────┘
Key insight: JavaScript NEVER runs two things at once.
It just switches REALLY FAST between tasks.
The Three Parts
// Part 1: Call Stack (what's executing NOW)
function first() {
console.log('first');
second();
console.log('first done');
}
function second() {
console.log('second');
third();
console.log('second done');
}
function third() {
console.log('third');
}
first();
// Output: first → second → third → second done → first done
// Stack grows down, unwinds back up
// Part 2: Web APIs (browser/Node background work)
// setTimeout, fetch, DOM events, file I/O
// These run OUTSIDE the JavaScript thread
// Part 3: Task Queue (callbacks waiting to run)
// When a Web API finishes → its callback goes in the queue
// Event loop picks from queue ONLY when call stack is empty
setTimeout(fn, 0) Trick
console.log('1');
setTimeout(() => {
console.log('2');
}, 0);
console.log('3');
// Output: 1, 3, 2
// WHY? setTimeout puts callback in the queue, NOT on the stack
// The call stack must finish (print 3) before the queue is checked
// This is the core of async in JavaScript!
// Everything that's "async" is really just "deferred to the queue"
Microtasks vs Macrotasks
console.log('script start');
setTimeout(() => console.log('setTimeout'), 0);
Promise.resolve()
.then(() => console.log('promise 1'))
.then(() => console.log('promise 2'));
console.log('script end');
// Output:
// script start
// script end
// promise 1
// promise 2
// setTimeout
// WHY? Two queues, different priorities:
// 1. Microtask Queue (Promises, queueMicrotask, MutationObserver)
// → Runs FIRST, empties COMPLETELY before next macrotask
// 2. Macrotask Queue (setTimeout, setInterval, I/O, UI events)
// → Runs ONE task, then checks microtask queue again
Visual: Event Loop Cycle
┌──────────────────┐
│ Call Stack │ ← JavaScript executes here
└────────┬─────────┘
│ (empty?)
▼
┌──────────────────┐
│ Microtask Queue │ ← Promises (runs ALL before moving on)
└────────┬─────────┘
│ (empty?)
▼
┌──────────────────┐
│ Render (DOM) │ ← Browser paints if needed
└────────┬─────────┘
│
▼
┌──────────────────┐
│ Macrotask Queue │ ← setTimeout, I/O (runs ONE)
└────────┬─────────┘
│
└──→ Back to Call Stack
Real-World Examples
Example 1: Blocking the Event Loop
// ❌ This blocks EVERYTHING for 3 seconds
function heavy() {
const start = Date.now();
while (Date.now() - start < 3000) {} // Busy wait!
console.log('Done!');
}
document.getElementById('btn').addEventListener('click', () => {
console.log('clicked!'); // Won't fire until heavy() finishes!
});
heavy(); // Blocks for 3 seconds
// Clicks during those 3 seconds are QUEUED, not ignored
// ✅ Use async instead
async function heavy() {
// Break work into chunks
for (let i = 0; i < 1000000; i++) {
doWork();
if (i % 10000 === 0) {
await new Promise(r => setTimeout(r, 0)); // Yield to event loop
}
}
console.log('Done!');
}
Example 2: Why async/await Works
async function fetchUser() {
console.log('1. Before fetch');
const response = await fetch('/api/user');
// ^ Execution SUSPENDS here
// Function returns a Promise
// Event loop continues processing other things
// When fetch completes → callback goes in microtask queue
// → Execution RESUMES here
console.log('3. After fetch');
const user = await response.json();
console.log('5. After JSON parse');
return user;
}
console.log('A');
fetchUser();
console.log('B');
// Output: A, 1. Before fetch, B, 3. After fetch, 5. After JSON parse
// The function "pauses" at await but the event loop keeps running!
Example 3: Promise.all and the Event Loop
const p1 = fetch('/api/users').then(r => r.json());
const p2 = fetch('/api/posts').then(r => r.json());
const p3 = fetch('/api/comments').then(r => r.json());
// All three fetches start simultaneously (parallel I/O!)
// Each resolves independently → callbacks in microtask queue
const [users, posts, comments] = await Promise.all([p1, p2, p3]);
// Resolves when ALL three are done
// This is NOT parallel JavaScript execution!
// The I/O is parallel (handled by browser/Node)
// The processing of results is sequential (single thread)
Node.js Specifics
// Node.js has additional phases in its event loop:
// 1. Timers: setTimeout, setInterval callbacks
// 2. Pending callbacks: I/O callbacks deferred to next loop
// 3. Idle, prepare: Internal use
// 4. Poll: Retrieve new I/O events; execute I/O callbacks
// 5. Check: setImmediate callbacks
// 6. Close callbacks: close event callbacks
// Key difference: setImmediate vs setTimeout
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
// Output is NON-DETERMINISTIC (could be either order!)
// In Node.js: depends on which phase the script finishes in
// To guarantee order:
setImmediate(() => {
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
});
// Always: immediate, timeout
// (setImmediate callback runs → checks microtasks →
// checks timers queue → checks immediate queue)
Common Mistakes
// ❌ Mistake 1: Assuming setTimeout order
setTimeout(() => console.log('a'), 100);
setTimeout(() => console.log('b'), 100);
// a and b can come in ANY order (they're both "approximately" 100ms)
// ❌ Mistake 2: Forgetting await
async function getData() {
const result = fetchData(); // Missing await!
console.log(result); // Promise, not data!
}
// Fix: const result = await fetchData();
// ❌ Mistake 3: Blocking in a loop
for (const item of largeArray) {
processSync(item); // Blocks entire event loop
}
// Fix: Use setImmediate or process.nextTick for large batches
// ❌ Mistake 4: Not understanding that Promise callbacks are microtasks
console.log('start');
setTimeout(() => console.log('timeout'), 0);
queueMicrotask(() => console.log('microtask'));
console.log('end');
// Output: start, end, microtask, timeout
Quick Reference Card
| Concept | Queue Type | Priority |
|---|---|---|
Promise.then() |
Microtask | High (runs all) |
await |
Microtask | High |
queueMicrotask() |
Microtask | High |
MutationObserver |
Microtask | High |
setTimeout() |
Macrotask | Low (runs one) |
setInterval() |
Macrotask | Low |
setImmediate() |
Macrotask | Low |
I/O callbacks |
Macrotask | Low |
UI events |
Macrotask | Low |
Did this make the event loop click for you? Any questions?
Follow @armorbreak for more JavaScript content.
Top comments (0)