TLDR
- The event loop is Node.js's core mechanism for managing asynchronous operations through libuv, cycling through distinct phases (timers, pending callbacks, idle/prepare, poll, check, close)
- Microtasks execute before each phase transition, creating a predictable execution order that differs from macrotask-first browsers; promises and
process.nextTick() run before setTimeout() callbacks - libuv handles the heavy lifting with its thread pool for I/O operations, while the event loop coordinates phase execution on the main thread in a single-threaded model
- Common pitfalls like microtask starvation and phase confusion lead to performance issues and hard-to-debug timing bugs that understanding event loop architecture helps you avoid
Introduction
If you've built a Node.js application, you've relied on the event loop without fully understanding it. You write setTimeout(), handle promises, and execute file operations, but the magic that makes async operations coordinate smoothly happens in a layer most developers never inspect.
The Node.js event loop isn't magic. It's a well-engineered state machine built on libuv that orchestrates when callbacks execute. Understanding this architecture transforms you from someone who makes things work to someone who makes things work efficiently.
When microtasks starve I/O operations, when timers fire later than expected, when you wonder why two seemingly identical async patterns behave differently, you're actually observing event loop phases and microtask execution order at work. This post pulls back the curtain.
By the end of this post, you'll understand:
- How libuv manages the thread pool and I/O operations
- Why event loop phases matter and what happens in each one
- How microtasks interleave with phases (and why it breaks your assumptions)
- Real performance implications and how to spot problems in production
Let's start with the architecture.
The Event loop architecture: a Three-Layer Model
Node.js isn't single-threaded in the way most people think. The event loop runs on a single thread, but libuv manages a thread pool for I/O operations. Understanding this three-layer model prevents misconceptions that lead to performance bugs.
Layer 1: the main thread (Event Loop)
The event loop runs on Node.js's main thread. It cycles through phases, checking for work at each stage. Between phases, it processes microtasks. This is deterministic, measurable, and central to everything that follows.
Layer 2: the libuv Thread Pool
libuv spawns a thread pool (default 4 threads, configurable via UV_THREADPOOL_SIZE) that handles expensive I/O operations:
- File system operations (
fs.readFile(), fs.writeFile()) - DNS lookups (
dns.lookup(), not dns.resolve()) - Some cryptographic operations (
crypto.pbkdf2(), crypto.randomBytes()) - Compression operations (
zlib)
The thread pool is bounded. When all threads are busy, subsequent I/O operations queue. This is where you first encounter real performance constraints in Node.js.
Layer 3: the OS and Native APIs
For certain operations, libuv delegates directly to the operating system:
- Network I/O (sockets, TCP/UDP)
- Some platform-specific system calls
When the OS operation completes, libuv queues a callback into the appropriate event loop phase.
Event loop phases: the execution order
The event loop cycles through phases in this specific order. Understanding the order explains why your callbacks fire in unexpected sequences.
βββββββββββββββββββββββββββββββββββββββββββββββ
β libuv event loop β
βββββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββββββββ
β βββββββββββββββββββββββββββββββββββββββββββ β
β β timers phase β β
β β execute setTimeout/setInterval callbacksβ β
β βββββββββββββββββββββββββββββββββββββββββββ β
β β
β βββββββββββββββββββββββββββββββββββββββββββ β
β β execute microtasks & nextTick queue β β
β βββββββββββββββββββββββββββββββββββββββββββ β
β β
β βββββββββββββββββββββββββββββββββββββββββββ β
β β pending callbacks phase β β
β β execute I/O callbacks from prev cycle β β
β βββββββββββββββββββββββββββββββββββββββββββ β
β β
β βββββββββββββββββββββββββββββββββββββββββββ β
β β execute microtasks & nextTick queue β β
β βββββββββββββββββββββββββββββββββββββββββββ β
β β
β βββββββββββββββββββββββββββββββββββββββββββ β
β β idle, prepare phase (internal use) β β
β βββββββββββββββββββββββββββββββββββββββββββ β
β β
β βββββββββββββββββββββββββββββββββββββββββββ β
β β poll phase β β
β β wait for I/O events, execute callbacks β β
β βββββββββββββββββββββββββββββββββββββββββββ β
β β
β βββββββββββββββββββββββββββββββββββββββββββ β
β β execute microtasks & nextTick queue β β
β βββββββββββββββββββββββββββββββββββββββββββ β
β β
β βββββββββββββββββββββββββββββββββββββββββββ β
β β check phase β β
β β execute setImmediate callbacks β β
β βββββββββββββββββββββββββββββββββββββββββββ β
β β
β βββββββββββββββββββββββββββββββββββββββββββ β
β β execute microtasks & nextTick queue β β
β βββββββββββββββββββββββββββββββββββββββββββ β
β β
β βββββββββββββββββββββββββββββββββββββββββββ β
β β close callbacks phase β β
β β cleanup callbacks (socket.destroy) β β
β βββββββββββββββββββββββββββββββββββββββββββ β
β β
β βββββββββββββββββββββββββββββββββββββββββββ β
β β execute microtasks & nextTick queue β β
β βββββββββββββββββββββββββββββββββββββββββββ β
β β
β (loop repeats or exits)
1. Timers Phase
Executes callbacks for timers whose thresholds have been reached. This includes setTimeout() and setInterval().
Important: The event loop doesn't check timers at millisecond precision. It checks timers once per phase iteration. If you schedule setTimeout(..., 0), it runs in this phase, but the timing depends on how much work precedes it.
console.log('1. Script start');
setTimeout(() => {
console.log('3. setTimeout callback');
}, 0);
console.log('2. Script end');
// Output:
// 1. Script start
// 2. Script end
// 3. setTimeout callback
2. Microtask Queue (after each phase)
After the timers phase completes, the event loop drains the entire microtask queue before moving to the next phase. This is the critical difference from browser JavaScript.
Microtasks include:
- Promise callbacks (
.then(), .catch(), .finally()) process.nextTick() (technically separate but higher priority than Promise microtasks)queueMicrotask()- MutationObserver callbacks (browser, not Node.js)
3. Pending callbacks phase
Executes I/O callbacks that were deferred to this cycle. Not all I/O callbacks execute in the poll phase; some defer to this phase for scheduling reasons.
4. Idle/prepare phase
Internal use only. Used by libuv for maintenance. You don't interact with this phase directly.
5. Poll phase
The event loop blocks here, waiting for I/O events. If there are pending I/O operations, this phase pauses execution until events arrive or timers are ready.
This is where most I/O callbacks execute:
- Network socket data arrivals
- File system operation completions
- Database query results
If there are no pending I/O operations and no timers scheduled, the event loop stays in the poll phase indefinitely (or until work appears).
6. Check phase
Executes setImmediate() callbacks. Use setImmediate() when you want guaranteed execution after the current poll phase, regardless of timers.
7. Close callbacks phase
Executes close handlers like socket.on('close') and cleanup callbacks.
Microtasks vs. Macrotasks: the Node.js difference
The crucial difference between Node.js and browser JavaScript is microtask execution timing.
Browser model (wrong assumption)
Many developers assume Node.js works like browsers:
- Execute one macrotask (like
setTimeout callback) - Drain microtask queue
- Repeat
Node.js model (Correct)
Node.js drains the microtask queue between phases:
- Execute all ready timers (timers phase)
- Drain the entire microtask queue
- Execute all ready I/O callbacks (pending callbacks phase)
- Drain the entire microtask queue
- Poll for I/O events (poll phase)
- Drain the entire microtask queue
- Execute setImmediate callbacks (check phase)
- Drain the entire microtask queue
- Continue...
This difference causes subtle bugs when you assume browser timing:
// Browser behavior vs Node.js behavior differ here
setTimeout(() => {
console.log('A: setTimeout (macrotask)');
Promise.resolve().then(() => console.log('A1: Promise from setTimeout'));
}, 0);
Promise.resolve().then(() => {
console.log('B: Promise (microtask)');
setTimeout(() => console.log('B1: setTimeout from Promise'), 0);
});
setImmediate(() => {
console.log('C: setImmediate');
});
// Node.js output:
// B: Promise (microtask)
// A: setTimeout (macrotask)
// A1: Promise from setTimeout (microtask after timers phase)
// C: setImmediate
// B1: setTimeout from Promise (next cycle timers phase)
// Browser output (different!):
// B: Promise
// A: setTimeout
// A1: Promise from setTimeout
// B1: setTimeout from Promise (might appear earlier)
// C: setImmediate (doesn't exist in browser)
process.nextTick() vs Promise
Both are microtasks, but process.nextTick() has higher priority than Promise microtasks:
Promise.resolve().then(() => console.log('Promise'));
process.nextTick(() => console.log('nextTick'));
// Output:
// nextTick
// Promise
Internally, Node.js maintains two separate microtask queues:
nextTick queue (higher priority)microTaskQueue (for Promises and queueMicrotask())
It drains nextTick queue completely, then drains microTaskQueue, then continues to the next phase.
libuv Thread Pool: Where I/O Happens
Understanding the thread pool explains performance bottlenecks that feel mysterious without this knowledge.
Thread Pool Basics
const fs = require('fs');
const { resolve } = require('path');
// Each fs.readFile() call executes on a thread pool thread
for (let i = 0; i < 20; i++) {
fs.readFile(resolve(__dirname, 'large-file.txt'), (err, data) => {
if (err) throw err;
console.log(`Read file ${i}`);
});
}
// With default threadpool size of 4, only 4 operations run concurrently
// The remaining 16 queue and wait for threads to become available
The default thread pool size is 4, but you can increase it:
// Must be set before any thread pool operations
process.env.UV_THREADPOOL_SIZE = 128;
// Restart Node.js process for this to take effect
Operations using the Thread Pool
Not all I/O uses the thread pool. Network operations use OS-level non-blocking I/O directly:
const net = require('net');
const fs = require('fs');
// This uses OS-level async I/O (epoll/kqueue), not thread pool
const server = net.createServer((socket) => {
socket.pipe(process.stdout);
});
// This uses the thread pool
fs.readFile('large.bin', (err, data) => {
// ...
});
Consequences of Thread Pool exhaustion
When all thread pool threads are busy, subsequent thread pool operations queue and wait. If one operation blocks longer than expected, cascading delays result:
const fs = require('fs');
const crypto = require('crypto');
// Scenario: 4 thread pool threads
// These 4 operations occupy all threads
for (let i = 0; i < 4; i++) {
crypto.pbkdf2('password', 'salt', 100000, 64, 'sha512', () => {
console.log(`PBKDF2 ${i} done`);
});
}
// This fs operation queues and waits
fs.readFile('/etc/hosts', (err, data) => {
console.log('File read (delayed):', data.toString());
});
// Increasing threadpool helps:
process.env.UV_THREADPOOL_SIZE = 8;
When to Increase Thread Pool Size
Increase UV_THREADPOOL_SIZE when:
- You perform many file system operations concurrently
- You use CPU-intensive crypto operations (
pbkdf2, scrypt) - You compress/decompress data frequently
- You perform DNS lookups via
dns.lookup() (not dns.resolve())
Don't increase blindly. Each thread consumes memory (1-2 MB stack space per thread). Profile your workload first:
// Monitor thread pool with native diagnostics
const diagnosticsChannel = require('diagnostics_channel');
const channel = diagnosticsChannel.channel('libuv:fs');
channel.subscribe((message) => {
console.log('libuv fs operation:', message);
});
Microtask Starvation: a hidden performance problem
Microtask starvation occurs when promises or process.nextTick() callbacks continuously queue new microtasks, preventing the event loop from advancing to the next phase. This starves I/O operations.
The starvation scenario
const fs = require('fs');
function recursivePromise() {
return Promise.resolve().then(() => {
console.log('Microtask');
return recursivePromise(); // Queue another microtask
});
}
// Start infinite microtask chain
recursivePromise();
// Try to read a file
fs.readFile('/etc/hosts', (err, data) => {
console.log('File read completed');
});
// Result: File will never be read
// The event loop never leaves the microtask queue
// to reach the poll phase where file I/O completes
Why this happens
- Microtasks drain completely before the event loop advances to the next phase
- If a microtask queues another microtask, the queue never empties
- The event loop remains stuck, unable to process I/O callbacks
Detecting starvation
const cluster = require('cluster');
if (cluster.isMaster) {
const worker = cluster.fork();
setTimeout(() => {
// If worker is unresponsive after 5 seconds, likely starved
if (!worker.isDead()) {
console.log('Worker may be microtask starved');
worker.kill();
}
}, 5000);
} else {
// Worker process with starvation
function recursivePromise() {
return Promise.resolve().then(() => {
recursivePromise();
});
}
recursivePromise(); // Start starvation
}
Prevention strategies
Use setImmediate() to break the microtask chain:
function processDataRecursively(data, index = 0) {
if (index >= data.length) return;
// Process one item
const result = expensiveCalculation(data[index]);
// Schedule next batch in the next check phase, not as microtask
setImmediate(() => processDataRecursively(data, index + 1));
}
processDataRecursively(largeArray);
Or use a library that manages work scheduling:
const pLimit = require('p-limit');
// Limit concurrent promise resolutions
const limit = pLimit(10);
const promises = largeArray.map(item =>
limit(() => expensivePromiseOperation(item))
);
Promise.all(promises).then(() => console.log('Done'));
Practical code examples: Real-World patterns
Pattern 1: guaranteeing execution order
When you need absolute control over execution order:
const fs = require('fs');
// This is unpredictable
fs.readFile('file1.txt', (err, data1) => {
fs.readFile('file2.txt', (err, data2) => {
console.log(data1, data2);
});
});
// This is predictable and more performant
Promise.all([
new Promise((resolve, reject) => {
fs.readFile('file1.txt', (err, data) => {
if (err) reject(err);
resolve(data);
});
}),
new Promise((resolve, reject) => {
fs.readFile('file2.txt', (err, data) => {
if (err) reject(err);
resolve(data);
});
})
]).then(([data1, data2]) => {
console.log(data1, data2);
}).catch(err => console.error(err));
// Or with async/await (cleaner)
async function readFiles() {
try {
const [data1, data2] = await Promise.all([
fs.promises.readFile('file1.txt'),
fs.promises.readFile('file2.txt')
]);
console.log(data1, data2);
} catch (err) {
console.error(err);
}
}
Pattern 2: Deferring Execution with Phases
Execute different priorities at different phases:
const net = require('net');
const fs = require('fs');
// High priority: execute immediately (microtask)
Promise.resolve().then(() => {
console.log('HIGH PRIORITY: Memory housekeeping');
});
// Medium priority: execute after I/O events (check phase)
setImmediate(() => {
console.log('MEDIUM PRIORITY: Non-urgent logging');
});
// Low priority: defer to next cycle
setTimeout(() => {
console.log('LOW PRIORITY: Maintenance tasks');
}, 0);
// Network I/O: depends on when data arrives (poll phase)
const server = net.createServer((socket) => {
socket.on('data', (data) => {
console.log('DATA ARRIVED: Process immediately');
});
});
Pattern 3: monitoring Event Loop lag
Detect when event loop is overloaded:
const { performance } = require('perf_hooks');
class EventLoopLagMonitor {
constructor(thresholdMs = 100) {
this.thresholdMs = thresholdMs;
this.lastCheck = performance.now();
}
check() {
const now = performance.now();
const lag = now - this.lastCheck - 1000;
if (lag > this.thresholdMs) {
console.warn(`Event loop lag detected: ${lag.toFixed(2)}ms`);
}
this.lastCheck = now;
}
startMonitoring(intervalMs = 1000) {
setInterval(() => this.check(), intervalMs);
}
}
const monitor = new EventLoopLagMonitor(50);
monitor.startMonitoring();
// Now your application is monitored
// Excessive lag indicates:
// - Long-running synchronous code
// - Microtask starvation
// - Thread pool exhaustion
Pattern 4: Batching CPU-Intensive Work
Prevent CPU-bound operations from blocking I/O:
async function processBatchWithYield(items, processor) {
const results = [];
for (let i = 0; i < items.length; i++) {
results.push(processor(items[i]));
// Yield to event loop every N items
if (i % 100 === 0) {
await new Promise(resolve => setImmediate(resolve));
}
}
return results;
}
// Usage
processBatchWithYield(hugeArray, item => {
// CPU-intensive operation
return expensiveCalculation(item);
}).then(results => {
console.log('Processed:', results.length);
});
// Event loop can handle I/O between batches
Common Pitfalls and How to Avoid Them
Pitfall 1: Assuming setTimeout(..., 0) is Instant
setTimeout(..., 0) doesn't execute immediately. It schedules execution in the next timers phase, which happens after all previous timers and microtasks.
// Wrong expectation
console.log('1');
setTimeout(() => console.log('2'), 0);
console.log('3');
// Actual: 1, 3, 2 (not 1, 2, 3)
// Use process.nextTick for true next execution
console.log('1');
process.nextTick(() => console.log('2'));
console.log('3');
// Result: 1, 3, 2 (microtask runs after sync code)
Pitfall 2: Mixing setTimeout and setImmediate Without Understanding Order
The execution order depends on where you call them:
// In main script
setTimeout(() => console.log('timeout'));
setImmediate(() => console.log('immediate'));
// Output: unpredictable (usually immediate, then timeout)
// Inside I/O callback
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => console.log('timeout'));
setImmediate(() => console.log('immediate'));
});
// Output: always "immediate" then "timeout" (check phase before next timer)
Reason: setImmediate is always next. setTimeout depends on when the poll phase started.
Pitfall 3: Forgetting Promise Rejection Handling
Unhandled promise rejections silently fail in some Node.js versions:
// Dangerous: rejection silently fails
new Promise((resolve, reject) => {
reject(new Error('Something failed'));
});
// Handle rejections
Promise.reject(new Error('Oops')).catch(err => {
console.error('Caught:', err.message);
});
// Or use global handler
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled rejection:', reason);
// Log to monitoring system
sendToErrorTracking(reason);
});
Pitfall 4: DNS Lookups Blocking with dns.lookup()
dns.lookup() uses the thread pool. dns.resolve() doesn't. Choose wisely:
const dns = require('dns');
// Uses thread pool (can exhaust it)
dns.lookup('example.com', (err, address) => {
console.log(address);
});
// Uses OS resolver cache (non-blocking)
dns.resolve4('example.com', (err, addresses) => {
console.log(addresses);
});
// For best practice: use async version with promises
const dnsPromises = require('dns').promises;
dnsPromises.resolve4('example.com').then(addresses => {
console.log(addresses);
});
Pitfall 5: Not Handling Back-Pressure in Streams
The event loop can queue events faster than your handler processes them:
// Dangerous: memory explosion
readableStream.on('data', (chunk) => {
// If processing is slow, events pile up
expensiveProcessing(chunk);
});
// Safe: respect back-pressure
readableStream.on('data', (chunk) => {
const canContinue = writeStream.write(chunk);
if (!canContinue) {
readableStream.pause();
}
});
writeStream.on('drain', () => {
readableStream.resume();
});
// Or use pipe (handles back-pressure automatically)
readableStream.pipe(writeStream);
Performance Considerations
Measuring Event Loop Health
Use process.cpuUsage() and wall-clock time to detect issues:
const { performance } = require('perf_hooks');
async function measureEventLoopHealth() {
const startCpu = process.cpuUsage();
const startWall = performance.now();
// Simulate some work
await new Promise(resolve => setTimeout(resolve, 1000));
const cpuUsed = process.cpuUsage(startCpu);
const wallTime = performance.now() - startWall;
const blockingTime = wallTime - 1000;
console.log('CPU user:', cpuUsed.user / 1000, 'ms');
console.log('CPU system:', cpuUsed.system / 1000, 'ms');
console.log('Event loop blocking:', blockingTime, 'ms');
}
measureEventLoopHealth();
Optimization Priority
- Reduce synchronous work (biggest impact)
- Increase thread pool size for CPU-bound I/O
- Use clustering for parallelization
- Implement caching to avoid repeated expensive operations
- Use workers for truly parallel CPU work
// Before: blocking event loop
const result = expensiveSync(input);
res.send(result);
// After: use worker threads
const { Worker } = require('worker_threads');
const worker = new Worker('./expensive-worker.js');
worker.on('message', (result) => {
res.send(result);
});
worker.postMessage(input);
Visual Elements Suggestions
To enhance this post, consider adding:
- Interactive Event Loop Visualizer: A tool where readers input code and see the execution order with phase highlighting and microtask queue state visualization
- Phase Execution Timeline Diagram: Animated sequence showing all phases with callbacks executing in order, with clear timing annotations
- Thread Pool Queue Visualization: Diagram showing thread pool filling up and operations queuing when all threads are busy
- Microtask Starvation Animation: Step-by-step visual showing how microtasks prevent event loop advancement
- Execution Order Flowchart: Decision tree for determining where your callback executes based on API used
Conclusion
The Node.js event loop is deterministic once you understand its architecture. It's not magic. It's a state machine that cycles through phases, drains microtasks between phases, and coordinates work across the main thread and thread pool.
Master these concepts:
- Phases execute in order; understand which phase your callback enters
- Microtasks execute completely between phases, which differs from browser JavaScript
process.nextTick() runs before Promise microtasks (both before next phase)- The thread pool has limited threads; CPU-intensive I/O operations can exhaust it
- Microtask starvation prevents the event loop from advancing; use
setImmediate() to yield - Event loop lag indicates overload; monitor it in production
The event loop is the foundation of Node.js concurrency. Understanding it transforms debugging from "why is this slow?" to "this operation is starving that phase." It's the difference between guessing and knowing.
Next steps: Use an event loop visualizer on your own code. Refactor one performance problem by applying these concepts. Monitor event loop lag in production. Understanding gets proven through practice.
FAQ
Q: Is Node.js truly single-threaded?
Yes and no. The JavaScript execution is single-threaded, running on the event loop's main thread. However, libuv operates a thread pool for I/O operations, and the operating system handles network I/O asynchronously. So the concurrency model is single-threaded for user code, but the underlying system is multi-threaded.
Q: Why should I use setImmediate() instead of setTimeout(..., 0)?
setImmediate() guarantees execution in the check phase, after I/O events have been processed. setTimeout(..., 0) executes in the timers phase. If you're in the poll phase waiting for I/O, setImmediate() ensures the check phase runs next, not another timer check. It's more predictable and often what you intend.
Q: Can I increase the thread pool size indefinitely?
Technically yes, but practically no. Each thread consumes memory (1-2 MB stack). On a 1GB server, you could theoretically create 500+ threads, but context switching overhead becomes severe. Typical production servers use 32-128 threads depending on the workload. Profile your specific workload to find the sweet spot.
Q: How do I prevent microtask starvation?
Use setImmediate() to yield to other phases instead of queueing another microtask. If you have a loop that generates microtasks, break it with setImmediate() every N iterations. Alternatively, use libraries like p-limit or p-queue that manage concurrency and work distribution automatically.
Q: What's the difference between process.nextTick() and Promise.resolve().then()?
Both are microtasks, but process.nextTick() has higher priority. The event loop drains the entire nextTick queue before processing the Promise microtask queue. For guaranteeing something runs after the current JavaScript context but before any I/O: use process.nextTick(). For integrating with promise chains: use Promise microtasks.
Q: How do I detect if my application is event loop starved?
Monitor three signals: (1) Event loop lag using performance.now() timing between setImmediate() callbacks, (2) CPU usage vs. actual processing (high CPU with little output suggests sync work or starvation), (3) Request latency increasing while resource utilization is normal. When you see these, check for infinite microtask loops, large synchronous operations, or thread pool exhaustion.