The JavaScript Event Loop Explained Simply (2026)
Understanding the event loop is the difference between "Node.js works" and "I know why Node.js works."
The Big Picture
JavaScript is single-threaded.
But it handles thousands of concurrent connections.
How? The Event Loop.
Think of it like a restaurant with one chef:
→ One order at a time (single thread)
→ But while food cooks, take the next order (non-blocking I/O)
→ Never idle — always something to do
The Call Stack
function multiply(a, b) {
return a * b;
}
function square(n) {
return multiply(n, n);
}
function printSquare(x) {
const result = square(x);
console.log(result);
}
printSquare(4); // What happens?
Step-by-step execution:
┌─────────────┐
│ printSquare │ ← push
│ square │ ← push (calls square)
│ multiply │ ← push (calls multiply)
│ return 16 │ ← pop (multiply returns)
│ return 16 │ ← pop (square returns)
│ log: 16 │ ← pop (console.log executes)
└─────────────┘
Stack empties out → program ends
This is synchronous. Simple. Boring. Let's make it interesting:
Callbacks Enter The Chat
console.log('1');
setTimeout(() => {
console.log('2');
}, 1000);
console.log('3');
// Output: 1 → 3 → 2 (NOT 1 → 2 → 3!)
Why? Because setTimeout is asynchronous — it doesn't block the call stack.
The Full Event Loop Model
┌───────────────────────────┐
│ │
│ ┌───────────────────┐ │
│ │ Call Stack │ │ ← Your code runs here (LIFO)
│ │ - printSquare() │ │
│ │ - square() │ │
│ │ - multiply() │ │
│ └─────────┬─────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────┐ │
│ │ Web APIs │ │ ← Browser/Node runtime handles these:
│ │ - setTimeout │ │ (DOM events, fetch, timers, etc.)
│ │ - fetch │ │
│ │ - DOM events │ │
│ └─────────┬─────────┘ │
│ │ │
│ ▼ (callback ready)
│ ┌───────────────────┐ │
│ │ Callback Queue │ │ ← Waiting their turn
│ │ (Task Queue) │ │
│ └─────────┬─────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────┐ │
│ │ EVENT LOOP │ │ ← The conductor!
│ │ "Is stack empty?" │ │ If yes → dequeue callback → push to stack
│ └───────────────────┘ │
│ │
└───────────────────────────┘
Visual Walkthrough
console.log('Start');
setTimeout(() => {
console.log('Timeout 1 (0ms)');
}, 0);
setTimeout(() => {
console.log('Timeout 2 (100ms)');
}, 100);
Promise.resolve().then(() => {
console.log('Microtask');
});
console.log('End');
Output: Start → End → Microtask → Timeout 1 (0ms) → Timeout 2 (100ms)
Why does Microtask run before Timeout?
There are actually two queues:
Microtask Queue (higher priority):
- Promise.then/catch/finally
- queueMicrotask()
- MutationObserver
Macrotask Queue (lower priority):
- setTimeout / setInterval
- I/O callbacks
- UI rendering (browser)
Event Loop Priority:
1. Run everything in call stack until empty
2. Run ALL microtasks (drain entire microtask queue)
3. Run ONE macrotask
4. Go back to step 2 (check for new microtasks)
This means: Microtasks can starve macrotasks!
Practical Example: Why This Matters
Example 1: Non-Blocking I/O
const fs = require('fs');
console.log('Reading file...');
fs.readFile('/etc/hostname', 'utf8', (err, data) => {
// This callback goes to the queue
// Event loop picks it up when I/O completes
console.log('File contents:', data);
});
console.log('This logs BEFORE file read!');
// Output:
// Reading file...
// This logs BEFORE file read!
// File contents: <hostname>
The readFile offloads work to the OS (thread pool). Your JS keeps running. When data arrives, callback enters the queue.
Example 2: The Starvation Problem
let counter = 0;
// This creates an infinite loop of microtasks
function scheduleMicrotask() {
Promise.resolve().then(() => {
counter++;
if (counter < 10_000) {
scheduleMicrotask();
}
});
}
scheduleMicrotask();
setTimeout(() => {
console.log('Timer fired! Counter:', counter);
}, 0);
// When does "Timer fired!" log?
// Answer: NEVER (or after all 10,000 microtasks complete)
// The timer callback waits in the macrotask queue forever
Example 3: setImmediate vs setTimeout(fn, 0)
// Node.js specific
setTimeout(() => console.log('setTimeout'), 0);
setImmediate(() => console.log('setImmediate'));
// In Node.js: setImmediate fires FIRST (usually)
// Why? setImmediate checks the check queue (poll phase)
// setTimeout(0) uses the timer queue (timer phase)
// Timer phase comes before poll phase... but 0ms might round up
async/await Is Just Syntactic Sugar
// These are IDENTICAL from event loop perspective:
async function getData() {
const res = await fetch(url); // await = yield + resume
return res.json();
}
// Compiles to roughly:
function getData() {
return fetch(url)
.then(res => res.json());
}
// Each await pauses function execution
// Puts the rest into a microtask (.then callback)
// Continues when promise resolves
Common Misconceptions
❌ "setTimeout(fn, 0) runs immediately"
No. It runs as soon as the call stack is empty AND the current turn of the event loop finishes. Minimum delay is typically ~1ms (or more under load).
❌ "setTimeout guarantees timing"
No. It guarantees MINIMUM delay. If the stack is busy with heavy computation, your callback waits.
const start = Date.now();
setTimeout(() => {
console.log(`Actual delay: ${Date.now() - start}ms`);
}, 100);
// Block for 200ms
const end = Date.now() + 200;
while (Date.now() < end) {}
// Output: Actual delay: ~201ms (not 100!)
❌ "Web Workers add threads to JavaScript"
Workers have their OWN event loop, own stack, own memory. They don't share memory with main thread. Communication via message passing. True parallelism, but no shared state.
Performance Implications
// ❌ Blocking the event loop
app.get('/heavy', (req, res) => {
const result = crypto.pbkdf2Sync(password, salt, 100000, 512, 'sha256'); // Takes 500ms
res.json({ result });
});
// During this 500ms, NO other request can be processed!
// ✅ Offload or break up
app.get('/heavy', async (req, res) => {
const result = await new Promise((resolve) => {
// Use worker_threads or break into chunks
pbkdf2(password, salt, 100000, 512, 'sha256', (err, key) => {
resolve(key.toString('hex'));
});
});
res.json({ result });
});
// Or even simpler — use built-in async version if available
import { pbkdf2 } from 'node:crypto';
const result = await pbkdf2(password, salt, 100000, 512, 'sha256');
// Doesn't block because it uses thread pool internally
Debugging Event Loop Issues
// Latency measurement
function measureLatency(label) {
const start = process.hrtime.bigint();
return {
done: () => {
const ns = Number(process.hrtime.bigint() - start);
const ms = ns / 1_000_000;
console.log(`${label}: ${ms.toFixed(2)}ms`);
return ms;
}
}
// Usage
const m = measureLatency('API Call');
await fetchData();
m.done(); // API Call: 45.23ms
// Detect event loop blocking
let lastTime = Date.now();
setInterval(() => {
const now = Date.now();
const delta = now - lastTime;
lastTime = now;
if (delta > 50) { // More than 50ms between ticks = blocked
console.warn(`⚠️ Event loop blocked for ${delta}ms!`);
}
}, 10); // Check every 10ms
Quick Reference
| Mechanism | Queue Type | Typical Use |
|---|---|---|
Promise.then |
Microtask | Async flow control |
queueMicrotask() |
Microtask | Explicit microtask scheduling |
process.nextTick() |
Microtask (Node) | After current operation |
setTimeout(fn, 0) |
Macrotask | "After current code" |
setImmediate() |
Macrotask (Node) | "After I/O phase" |
setInterval() |
Macrotask | Repeating tasks |
requestAnimationFrame() |
Macrotask (Browser) | Before next paint |
Does the event loop finally click for you?
Follow @armorbreak for more deep-dive JS guides.
Top comments (0)