Node.js Event Loop: Visual Guide to Async Programming (2026)
The event loop is what makes Node.js fast. Understanding it means writing faster, bug-free async code.
The Big Picture
┌──────────────────────────────────┐
│ │
│ ┌─────┐ ┌──────────────────┐ │
│ │ Timers│ │ Check │ │
│ │(set │ │ (setImmediate) │ │
│ │Timeout│ │ │ │
│ └──┬──┘ └────────┬─────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────────────────────┐ │
│ │ Poll / I/O Callbacks │ │ ← Where most of your code runs!
│ └────────────┬─────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────┐ │
│ │ Close Callbacks │ │
│ └──────────────────────────┘ │
│ │
└──────────────────────────────────┘
Key insight: JavaScript is SINGLE-THREADED.
But I/O (network, file system) happens on SEPARATE threads.
The event loop coordinates everything.
How It Actually Works
// Each "tick" of the event loop does this:
// Phase 1: Timers (setTimeout, setInterval)
// → Check if any timer has expired. If yes, run its callback.
// Phase 2: Pending callbacks (I/O errors, etc.)
// → Run callbacks deferred from previous I/O operations.
// Phase 3: Poll (the main phase!)
// → Execute I/O callbacks (fs.readFile, http.request, etc.)
// → If no callbacks: wait for new I/O OR run setImmediate callbacks.
// → This is where your app spends most of its time.
// Phase 4: Check (setImmediate callbacks)
// → Run all setImmediate callbacks.
// Phase 5: Close callbacks
// → Run socket.on('close'), etc.
// Then... back to Phase 1! (loop!)
Microtasks vs Macrotasks
// CRITICAL distinction that causes bugs:
console.log('1. Script start');
setTimeout(() => console.log('4. setTimeout (macrotask)'), 0);
Promise.resolve()
.then(() => console.log('2. Promise.then (microtask)'))
.then(() => console.log('3. Promise.then #2 (microtask)'));
setImmediate(() => console.log('5. setImmediate (macrotask)'));
process.nextTick(() => console.log('0. process.nextTick (microtask)'));
// Output order:
// 1. Script start
// 0. process.nextTick (microtask) ← HIGHEST priority microtask!
// 2. Promise.then (microtask)
// 3. Promise.then #2 (microtask)
// 4. setTimeout (macrotask)
// 5. setImmediate (macrotask)
// Priority order:
// 1. process.nextTick (same tick, after current operation completes)
// 2. Promises (.then/.catch/.finally) — microtask queue
// 3. Other macrotasks in order: timers → poll → check → close
Common Mistakes & Fixes
Mistake 1: Blocking the Event Loop
// ❌ Blocking: CPU-heavy operation freezes EVERYTHING
app.get('/compute', (req, res) => {
const result = heavyComputation(data); // Blocks for 5 seconds!
res.json({ result });
});
// During those 5 seconds: NO requests handled, NO timers fire, NO heartbeats!
// ✅ Fix 1: Offload to worker thread
const { Worker, isMainThread, parentPort, workerData } = require('worker_threads');
if (isMainThread) {
app.get('/compute', (req, res) => {
const worker = new Worker(__filename, {
workerData: { data: req.body.data }
});
worker.on('message', (result) => {
res.json({ result }); // Main thread stays responsive!
});
});
} else {
const result = heavyComputation(workerData.data);
parentPort.postMessage(result);
}
// ✅ Fix 2: Break into chunks with setImmediate
function processLargeArray(array, callback) {
let index = 0;
const chunkSize = 1000;
function processChunk() {
const end = Math.min(index + chunkSize, array.length);
for (; index < end; index++) {
processItem(array[index]); // Process one item
}
if (index < array.length) {
setImmediate(processChunk); // Yield to event loop, then continue
} else {
callback(); // Done!
}
}
processChunk();
}
// Event loop stays responsive between chunks!
// ✅ Fix 3: Use native async APIs (libuv handles threading)
const crypto = require('crypto');
// crypto.pbkdf2 runs on thread pool — doesn't block event loop!
crypto.pbkdf2(password, salt, iterations, keylen, (err, derivedKey) => {
res.json({ key: derivedKey.toString('hex') });
});
Mistake 2: Unhandled Promise Rejections
// ❌ Silent failure
async function fetchData() {
throw new Error('Network error'); // Unhandled rejection!
}
fetchData(); // No .catch() → warning in Node.js, crash in future versions!
// ✅ Always handle rejections (at minimum):
fetchData().catch(err => console.error('Fetch failed:', err));
// ✅ Or use global handler as safety net:
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
// In production: log to error tracking service!
});
process.on('uncaughtException', (err) => {
console.error('Uncaught Exception:', err);
// Don't just ignore! Log + graceful shutdown:
cleanupAndExit(1);
});
// ✅ Best: Top-level await with try/catch in main file:
try {
await startServer();
} catch (err) {
console.error('Failed to start:', err);
process.exit(1);
}
Mistake 3: Callback Hell vs Async/Await Anti-Patterns
// ❌ Old school: Callback hell
fs.readFile('config.json', (err, data) => {
if (err) throw err;
const config = JSON.parse(data);
db.connect(config.dbUrl, (err, client) => {
if (err) throw err;
client.query('SELECT * FROM users', (err, rows) => {
if (err) throw err;
res.json(rows); // Good luck reading this...
});
});
});
// ✅ Modern: Clean async/await
async function handleRequest(req, res) {
try {
const config = JSON.parse(await fs.promises.readFile('config.json'));
const client = await db.connect(config.dbUrl);
const rows = await client.query('SELECT * FROM users');
res.json(rows);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Internal error' });
}
}
// ⚠️ But avoid THIS anti-pattern:
// ❌ Fire-and-forget (unhandled promise!)
app.get('/data', async (req, res) => {
const data = await fetchData(); // If this throws → unhandled rejection!
res.json(data);
});
// ✅ Wrap route handlers properly:
const asyncHandler = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
app.get('/data', asyncHandler(async (req, res) => {
const data = await fetchData(); // Errors go to Express error middleware!
res.json(data));
}));
Practical Patterns
Pattern 1: Request Coalescing
// Multiple requests for same data within window → single fetch
class RequestCache {
constructor(ttl = 1000) {
this.cache = new Map();
this.ttl = ttl;
}
async get(key, fetcher) {
const cached = this.cache.get(key);
// Return cached if fresh
if (cached && Date.now() - cached.timestamp < this.ttl) {
return cached.promise;
}
// If already fetching (in-flight), return same promise
if (cached && !cached.resolved) {
return cached.promise; // Deduplicates concurrent requests!
}
// New request
const promise = fetcher().then(data => {
this.cache.get(key).resolved = true;
return data;
});
this.cache.set(key, { promise, timestamp: Date.now(), resolved: false });
return promise;
}
}
// Usage: 100 simultaneous requests for same user → 1 actual API call
const userCache = new RequestCache(5000); // 5s cache
app.get('/user/:id', async (req, res) => {
const user = await userCache.get(`user:${req.params.id}`, () =>
fetchUserFromAPI(req.params.id)
);
res.json(user);
});
Pattern 2: Rate Limiting with Queue
// Limit concurrent async operations (e.g., API calls, DB queries)
class ConcurrencyLimiter {
constructor(maxConcurrent = 5) {
this.maxConcurrent = maxConcurrent;
this.running = 0;
this.queue = [];
}
async execute(fn) {
if (this.running >= this.maxConcurrent) {
// Wait for slot to open
await new Promise(resolve => this.queue.push(resolve));
}
this.running++;
try {
return await fn();
} finally {
this.running--;
if (this.queue.length > 0) {
this.queue.shift()(); // Let next waiting task proceed
}
}
}
}
// Usage: Process 1000 files, but only 5 at a time
const limiter = new ConcurrencyLimiter(5);
await Promise.all(
files.map(file => limiter.execute(() => processFile(file)))
);
Pattern 3: Timeout Wrapper
// Add timeout to ANY async operation
function withTimeout(promise, ms, message = 'Operation timed out') {
return Promise.race([
promise,
new Promise((_, reject) =>
setTimeout(() => reject(new Error(message)), ms)
)
]);
}
// Usage:
const data = await withTimeout(
fetchFromSlowAPI(),
3000,
'API response took too long'
);
// Combined with retry:
async function fetchWithRetry(url, options = {}) {
const { maxRetries = 3, timeout = 5000, delay = 1000 } = options;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await withTimeout(fetch(url), timeout);
} catch (err) {
if (attempt === maxRetries) throw err;
console.warn(`Attempt ${attempt} failed, retrying in ${delay}ms...`);
await new Promise(r => setTimeout(r, delay * attempt)); // Exponential backoff
}
}
}
Debugging Async Code
// Enable async stack traces (Node.js 16+)
// --async-stack-traces flag or via code:
Error.stackTraceLimit = Infinity; // Show full stack traces
// Track active promises (debugging hanging operations):
const activePromises = new Set();
function trackableFetch(url) {
const promise = fetch(url).finally(() => activePromises.delete(promise));
activePromises.add(promise);
// Auto-warn if promise takes too long
setTimeout(() => {
if (activePromises.has(promise)) {
console.warn(`⚠️ Pending promise for ${url} (>10s)`);
}
}, 10000);
return promise;
}
// Async hooks (advanced debugging):
import async_hooks from 'async_hooks';
const hook = async_hooks.createHook({
init(asyncId, type, triggerAsyncId) {
if (type === 'PROMISE') {
// Track every promise creation
}
},
destroy(asyncId) {
// Track cleanup
},
});
hook.enable();
// Practical: Measure async operation duration
function measureTime(label) {
const start = process.hrtime.bigint();
return {
end(result) {
const duration = Number(process.hrtime.bigint() - start) / 1e6;
console.log(`${label}: ${duration.toFixed(2)}ms`);
return result;
},
};
}
// Usage:
const timer = measureTime('DB query');
const rows = await db.query('SELECT * FROM users');
timer.end(rows);
What's the trickiest async bug you've ever debugged?
Follow @armorbreak for more practical developer guides.
Top comments (0)