The JavaScript Event Loop Isn't Magic
TL;DR
JavaScript runs on a single thread with an event loop. Synchronous code runs to completion. Async callbacks wait in queues. Microtasks (Promises) run before macrotasks (setTimeout). Blocking the call stack freezes everything.
A senior developer told me he got through five years of JavaScript without understanding the event loop. Then he tried to optimize a React app with 500ms freezes and had to learn it all in one weekend. Understanding it upfront is faster.
Here's how JavaScript actually handles concurrency.
Single Thread, But Not Sequential
JavaScript has one thread. One call stack. One thing runs at a time.
console.log('1');
console.log('2');
console.log('3');
// Output: 1, 2, 3
// Obvious
But:
console.log('1');
setTimeout(() => console.log('2'), 0);
console.log('3');
// Output: 1, 3, 2
// Why does 2 come last even with 0ms delay?
This is the event loop.
The Components
┌─────────────────────────────────────┐
│ Call Stack │
│ │
│ main() │
│ console.log() │
└──────────────┬──────────────────────┘
│
↓
┌─────────────────────────────────────┐
│ Web APIs / Node APIs │
│ │
│ setTimeout, setInterval │
│ fetch, XMLHttpRequest │
│ fs.readFile, net.connect │
│ DOM events │
└──────────────┬──────────────────────┘
│
↓
┌──────────────────────┬──────────────┐
│ Microtask Queue │ Macrotask │
│ (Promise .then) │ Queue │
│ (queueMicrotask) │ (setTimeout) │
│ (MutationObserver) │ (setInterval)│
│ │ (I/O events) │
└──────────────┬───────┴──────────────┘
│
↓
Event Loop checks:
"Call stack empty?
Run all microtasks first,
then one macrotask."
Step-by-Step Walk-Through
console.log('start');
setTimeout(() => {
console.log('timeout');
}, 0);
Promise.resolve()
.then(() => {
console.log('promise 1');
})
.then(() => {
console.log('promise 2');
});
console.log('end');
// Output: start, end, promise 1, promise 2, timeout
What happens:
1. console.log('start') → runs → prints "start"
2. setTimeout(cb, 0) → registered with Timer API → cb queued in macrotask
3. Promise.resolve().then(cb1).then(cb2) → cb1 queued in microtask queue
4. console.log('end') → runs → prints "end"
5. Call stack empty. Event loop checks:
- Microtask queue: cb1 → runs → prints "promise 1"
- cb1 resolved, cb2 queued in microtask → runs → prints "promise 2"
- Microtask queue empty
- Macrotask queue: setTimeout cb → runs → prints "timeout"
Key rule: All microtasks run before the next macrotask.
Microtasks vs Macrotasks
// Microtasks (drain completely before next macrotask):
Promise.resolve().then(() => {});
queueMicrotask(() => {});
// Macrotasks (run one at a time, after all microtasks):
setTimeout(() => {}, 0);
setInterval(() => {}, 100);
// Also: I/O callbacks, UI rendering events
// Microtasks can starve macrotasks
function infiniteMicrotasks() {
Promise.resolve().then(infiniteMicrotasks);
}
infiniteMicrotasks();
// setTimeout callbacks NEVER run
// Microtask queue is always non-empty
Why async/await Works the Way It Does
async/await is syntactic sugar over Promises:
async function fetchData() {
console.log('before await'); // Runs synchronously
const result = await fetch('/api'); // Suspends, returns to caller
console.log('after await'); // Queued as microtask when fetch resolves
return result;
}
console.log('before call');
fetchData();
console.log('after call');
// Output: before call, before await, after call, after await
await suspends the function and returns control to the event loop. The continuation runs as a microtask when the awaited value resolves.
The Call Stack and Blocking
// BAD - blocks the entire thread for seconds
function fibonacci(n) {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
fibonacci(45); // ~5 seconds of blocked execution
// During these 5 seconds:
// - No user interactions respond
// - No timers fire
// - No network responses are processed
// - Browser tab is completely frozen
// GOOD - yield to event loop periodically
function processBatch(items, chunkSize = 1000) {
return new Promise((resolve) => {
let index = 0;
const results = [];
function processChunk() {
const end = Math.min(index + chunkSize, items.length);
for (; index < end; index++) {
results.push(heavyOperation(items[index]));
}
if (index < items.length) {
// Yield to event loop, continue next tick
setTimeout(processChunk, 0);
} else {
resolve(results);
}
}
processChunk();
});
}
Node.js Event Loop Phases
Node.js has a more specific event loop with named phases:
timers → setTimeout, setInterval callbacks
pending callbacks → I/O errors from previous iteration
poll → Retrieve new I/O events, run their callbacks
check → setImmediate callbacks
close callbacks → socket.on('close', ...)
// Node.js: setImmediate vs setTimeout(0)
setImmediate(() => console.log('setImmediate'));
setTimeout(() => console.log('setTimeout'), 0);
// Order can vary at top level
// Inside an I/O callback, setImmediate always runs first
fs.readFile('file.txt', () => {
setImmediate(() => console.log('immediate')); // Runs first
setTimeout(() => console.log('timeout'), 0); // Runs second
});
// process.nextTick runs before other microtasks
process.nextTick(() => console.log('nextTick'));
Promise.resolve().then(() => console.log('promise'));
// Output: nextTick, promise
Real-World Problems This Explains
Why UI freezes
// BAD - parse large JSON on main thread
document.getElementById('upload').addEventListener('change', (e) => {
const reader = new FileReader();
reader.onload = (event) => {
const data = JSON.parse(event.target.result); // 500ms on large file
// UI frozen during parse
renderData(data);
};
reader.readAsText(e.target.files[0]);
});
// GOOD - offload to Web Worker
const worker = new Worker('parser.worker.js');
document.getElementById('upload').addEventListener('change', (e) => {
worker.postMessage({ file: e.target.files[0] });
// Main thread free immediately
});
worker.onmessage = (event) => {
renderData(event.data); // Receives parsed result
};
// parser.worker.js (runs in separate thread, can block freely)
self.onmessage = async (event) => {
const text = await event.data.file.text();
const data = JSON.parse(text);
self.postMessage(data);
};
Why Promise chains work
// Each .then() returns a new Promise
// Resolved value flows through the chain
fetch('/api/user')
.then(response => response.json()) // Microtask 1
.then(user => fetch(`/api/posts/${user.id}`)) // Microtask 2 (new fetch)
.then(response => response.json()) // Microtask 3
.then(posts => render(posts)); // Microtask 4
// Each callback runs as a microtask
// Runs after current stack clears, before any setTimeout
Why setTimeout(fn, 0) defers execution
// This runs BEFORE the DOM updates in the same synchronous block
button.click();
console.log(button.textContent); // May see old value
// This runs AFTER current synchronous code + DOM update
setTimeout(() => {
console.log(button.textContent); // Sees new value
}, 0);
// Common pattern: defer until after render
function afterRender(callback) {
requestAnimationFrame(() => {
setTimeout(callback, 0);
});
}
Common Mistakes
Expecting sequential behavior from async code
// BAD - assumes getData finishes before log
getData();
console.log(data); // undefined! getData is async
// GOOD
const data = await getData();
console.log(data);
Blocking with synchronous operations in async functions
// BAD - sync I/O blocks event loop even inside async function
async function handler(req, res) {
const data = fs.readFileSync('huge-file.json'); // Blocks everything!
res.json(JSON.parse(data));
}
// GOOD - async I/O
async function handler(req, res) {
const data = await fs.promises.readFile('huge-file.json', 'utf8');
res.json(JSON.parse(data));
}
The Bottom Line
The event loop is JavaScript's solution to concurrency on a single thread. Async operations wait in queues and run when the call stack is empty.
Key points:
- Synchronous code blocks everything—never do CPU-intensive work on the main thread
- Microtasks (Promises) run before macrotasks (setTimeout), after each stack frame empties
awaitsuspends and returns control, it doesn't block the thread- Long synchronous operations freeze UI and prevent callbacks from running
- Use Web Workers for CPU work, async I/O for everything else
Once you see the event loop, setTimeout(fn, 0) tricks, Promise ordering, and UI freezes all make sense. It's not magic—it's a queue.