Node.js Event Loop: Understanding the Async Heartbeat (2026)
The event loop is what makes Node.js "async" and "non-blocking". Understanding it isn't academic — it's the difference between an app that handles 10 connections and one that handles 10,000.
The Mental Model
Think of the event loop as a loop that keeps checking:
┌─────────────────────────────────────┐
│ 1. Run any TIMERS (setTimeout, etc) │
│ 2. Run PENDING CALLBACKS │
│ 3. POLL for new I/O events │
│ 4. Run SET IMMEDIATE callbacks │
│ 5. CLOSE callbacks │
│ → Back to step 1 │
└─────────────────────────────────────┘
Key insight: JavaScript is SINGLE-THREADED.
The event loop is just a clever scheduling mechanism.
Only ONE thing runs at a time.
"Async" means "I'll come back to this later when data arrives",
not "this runs on another thread".
The 6 Phases in Detail
// Each iteration of the event loop has 6 phases:
// Timers → Pending Callbacks → Idle/Prepare → Poll → Check → Close Callbacks
// Phase 1: TIMERS
// setTimeout and setInterval callbacks run here
// ⚠️ NOT guaranteed to run at exact time! Just "no earlier than"
console.log('Start');
setTimeout(() => console.log('Timer: 0ms'), 0);
setTimeout(() => console.log('Timer: 100ms'), 100);
console.log('End');
// Output: Start → End → Timer: 0ms → Timer: 100ms
// Even 0ms timer doesn't run immediately!
// Phase 2: PENDING CALLBACKS
// I/O error callbacks from previous loop iterations
// Usually handled by system internals
// Phase 3: IDLE/PREPARE
// Internal use only (libuv internals)
// prepare: run setImmediate() callbacks scheduled here
// Phase 4: POLL (the most important phase!)
// Retrieves new I/O events
// - If poll queue not empty → process callbacks
// - If empty AND no timers → wait for I/O (may block here!)
// - If empty BUT has timers → move to next phase (don't block)
// Phase 5: CHECK (setImmediate)
// Runs setImmediate() callbacks
// This is why setImmediate beats setTimeout(fn, 0):
setImmediate(() => console.log('immediate'));
setTimeout(() => console.log('timeout'), 0);
// Output: immediate → timeout (always! in same callback context)
// Phase 6: CLOSE CALLBACKS
// socket.on('close', ...) etc.
Microtasks vs Macrotasks
// CRITICAL distinction that affects execution order:
// MACROTASKS (run between event loop phases):
// setTimeout, setInterval, setImmediate, I/O callbacks, UI rendering
// MICROTASKS (run after each macrotask completes, before next macrotask):
// Promise.then/.catch/.finally, queueMicrotask(), MutationObserver
// Execution order example:
console.log('1: script start');
setTimeout(() => {
console.log('2: setTimeout');
}, 0);
Promise.resolve()
.then(() => console.log('3: promise 1'))
.then(() => console.log('4: promise 2'));
queueMicrotask(() => console.log('5: microtask'));
console.log('6: script end');
// Output order:
// 1: script start ← synchronous code first
// 6: script end ← synchronous code finishes
// 3: promise 1 ← microtask queue drains completely
// 5: microtask ← before next macrotask
// 4: promise 2 ← still draining microtasks!
// 2: setTimeout ← now macrotask runs
// ⚠️ Microtask infinite loop blocks EVERYTHING:
Promise.resolve().then(async function loop() {
console.log('stuck in microtask!');
await Promise.resolve(); // Still a microtask!
return loop(); // Never lets event loop continue!
});
// No timers fire, no I/O processes, nothing else works!
Practical Implications
// Problem #1: Blocking the event loop
function blockingOperation() {
// CPU-intensive work blocks everything:
const start = Date.now();
while (Date.now() - start < 5000) {
// Spinning CPU for 5 seconds
// During this time: NO requests handled, NO timers fire, NOTHING
}
}
// Fix: Use Worker threads or break into chunks with setImmediate
// Fix: Yield control periodically
function nonBlockingWork(items, callback) {
let index = 0;
function processChunk() {
const chunkEnd = Math.min(index + 1000, items.length);
for (; index < chunkEnd; index++) {
heavyProcessing(items[index]);
}
if (index < items.length) {
setImmediate(processChunk); // Let other things run between chunks
} else {
callback();
}
}
processChunk();
}
// Problem #2: Starving I/O with CPU work
// If you have constant computation, I/O callbacks never get to run!
// Solution: Worker threads for CPU work:
const { Worker } = require('worker_threads');
const worker = new Worker('./heavy-computation.js', { workerData: largeDataset });
worker.on('message', (result) => {
console.log('Result:', result); // Main thread stays responsive!
});
// Problem #3: Unhandled rejections in microtasks
// A rejected promise without .catch() creates a "unhandled rejection"
// But it's reported asynchronously (via microtask), which can be confusing:
Promise.reject(new Error('oops'));
console.log('This prints BEFORE the unhandled rejection warning');
// Because the rejection goes through microtask queue first
// Best practice: Always handle errors at each level:
async function safeAPICall() {
try {
const data = await fetch('/api/data');
return await data.json();
} catch (err) {
console.error('API call failed:', err.message);
throw err; // Re-throw if caller needs to know
}
}
// Problem #4: process.nextTick vs setImmediate vs setTimeout
process.nextTick(() => console.log('nextTick')); // Before all phases (microtask-like)
setImmediate(() => console.log('setImmediate')); // After poll phase
setTimeout(() => console.log('setTimeout 0'), 0); // After timers phase
// Order: nextTick → setImmediate → setTimeout(0)
// Use nextTick for: cleanup after current operation (must run before I/O)
// Use setImmediate for: "after I/O but before timers" operations
// Use setTimeout for: actual delayed execution (minimum 1ms even if 0)
Debugging Event Loop Issues
// Detect if your event loop is blocked:
let lastTime = process.hrtime.bigint();
const BLOCK_THRESHOLD_MS = 100; // Alert if loop blocked > 100ms
function checkLoopDelay() {
const now = process.hrtime.bigint();
const delayMs = Number(now - lastTime) / 1_000_000;
if (delayMs > BLOCK_THRESHOLD_MS) {
console.warn(`Event loop blocked for ${delayMs.toFixed(0)}ms`);
}
lastTime = now;
}
// Run this check frequently via interval:
setInterval(checkLoopDelay, 1000);
// Or use built-in diagnostics:
// --enable-source-maps flag for better async stack traces
// NODE_DEBUG=async_hooks node app.js // See internal async operations
// Performance hooks (Node.js 12+):
const { performance, PerformanceObserver } = require('perf_hooks');
const obs = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.entryType === 'node') {
console.log(`${entry.name}: ${entry.duration}ms`);
}
}
});
obs.observe({ entryTypes: ['node'] });
// Measure specific async operations:
performance.mark('db-query-start');
await db.query('SELECT * FROM users');
performance.mark('db-query-end');
performance.measure('database query', 'db-query-start', 'db-query-end');
What's the most confusing aspect of the event loop? Have you ever debugged a blocking issue?
Follow @armorbreak for more practical developer guides.
Top comments (0)