Node.js 9 🟢 The Node.js Event Loop Architecture and Libuv Thread Pool
The event loop is the mechanism that allows Node.js to perform non-blocking I/O despite JavaScript being single-threaded. It is implemented by libuv, the C library that underpins Node.js’s asynchronous behavior. When JavaScript executes an asynchronous operation, it does not wait for the result. Instead, it registers a callback and yields control back to the event loop, which continues processing other work. When the operation completes, the callback is queued and eventually executed .
What makes this architecture work is a division of labor between two types of threads. The event loop thread executes all JavaScript code, including callbacks, and handles non-blocking I/O like network sockets. A separate pool of worker threads handles operations that lack non-blocking OS primitives: file system operations, DNS lookups, and some cryptographic functions . Understanding which operations use which thread is the key to diagnosing performance problems and avoiding the common mistake of blocking the event loop.
This chapter covers the event loop phases, the libuv thread pool, the operations that use each, and the patterns for avoiding event loop starvation.
Key point: The event loop executes all JavaScript and handles non-blocking network I/O. The libuv thread pool, defaulting to 4 threads, handles file system operations, DNS lookups, and certain crypto functions. Blocking the event loop stops all JavaScript execution; saturating the thread pool delays file and DNS operations.
Why the event loop and thread pool exist
The single-threaded problem. JavaScript was designed for browsers where blocking the main thread freezes the UI. Node.js inherited this model, which means all JavaScript executes on a single thread. If any operation blocks that thread, no other JavaScript can run, and the entire application stalls .
The non-blocking I/O solution. Most I/O operations do not need to block. Network sockets can be monitored by the operating system’s event notification mechanism—epoll on Linux, kqueue on macOS, IOCP on Windows. When data arrives or a socket becomes writable, the OS notifies the event loop, which then runs the appropriate callback. No thread is blocked waiting for network I/O .
The blocking-operation problem. File system operations are different. The POSIX API for reading and writing files is synchronous at the OS level. There is no portable non-blocking file I/O across Linux, macOS, and Windows. Rather than block the event loop, libuv delegates file operations to a thread pool. The worker thread performs the blocking read or write, and when it completes, the result is passed back to the event loop, which executes the callback .
The DNS problem. DNS resolution via getaddrinfo is also a blocking system call. It reads /etc/hosts, consults resolver configuration, and may perform network operations that the OS handles synchronously. Like file I/O, getaddrinfo runs on the thread pool. In contrast, dns.resolve*() methods use the c-ares library, which implements DNS resolution asynchronously without the thread pool .
The CPU-bound problem. Some JavaScript operations are CPU-intensive: image processing, data compression, cryptographic hashing. These cannot be made non-blocking because they are pure computation. They must either run on the event loop (blocking everything) or be moved to a worker thread. The worker_threads module provides true parallelism for CPU-bound work, separate from the libuv thread pool .
a. The event loop phases
The event loop processes callbacks in a defined sequence of phases. Each phase has a queue of callbacks, and the loop processes the queue before moving to the next phase .
Timers phase. Executes callbacks scheduled by setTimeout() and setInterval(). Timers are grouped in a priority queue, and those whose delay has elapsed are executed. Timers are not precise: they execute after at least the specified delay, not exactly at it. A timer scheduled for 100ms may execute at 105ms if the loop was busy .
Pending callbacks phase. Executes callbacks for certain system operations, such as TCP errors. This phase is rarely a concern in application code.
Poll phase. The most important phase. It has two functions: calculating how long to block waiting for I/O, and processing I/O callbacks. When the poll queue is empty, the loop blocks until an I/O event arrives or the next timer is ready. If setImmediate() callbacks are scheduled, the loop does not block and proceeds to the check phase .
Check phase. Executes callbacks scheduled by setImmediate(). This phase runs immediately after the poll phase completes, which is why setImmediate() callbacks always execute before setTimeout() callbacks when both are scheduled inside an I/O callback .
Close callbacks phase. Executes callbacks for closed resources, such as socket close events.
process.nextTick and microtasks. These are not technically part of the event loop. The nextTick queue and the Promise microtask queue are processed after each operation completes, regardless of which phase the loop is in. This means process.nextTick() callbacks run before the event loop continues to the next phase .
b. The libuv thread pool
The thread pool is a global resource shared across all event loops in a process. It is initialized lazily on the first operation that requires it, and once initialized, its size is fixed for the process lifetime .
Default size. The pool defaults to 4 threads. This can be changed by setting the UV_THREADPOOL_SIZE environment variable before the process starts. The maximum is 1024, increased from 128 in libuv 1.30.0 .
Operations that use the pool. File system operations (all fs APIs except fs.FSWatcher() and synchronous methods), DNS lookups via getaddrinfo and getnameinfo, certain crypto functions (pbkdf2, scrypt, randomBytes, generateKeyPair), and all zlib compression APIs except synchronous methods .
Operations that bypass the pool. Network I/O uses non-blocking sockets and the OS event notification mechanism. dns.resolve*() methods use c-ares and do not touch the thread pool .
The saturation problem. If four file reads are in progress and a fifth is requested, the fifth waits in a queue. Under load, the thread pool can become a bottleneck, delaying file operations and DNS lookups. Increasing UV_THREADPOOL_SIZE reduces queuing but increases memory consumption (each thread has an 8 MB stack) and context-switching overhead .
c. Blocking the event loop
The event loop executes all JavaScript. If a callback performs a long synchronous operation, no other JavaScript runs until it completes. This means no other callbacks execute, no timers fire, and no I/O events are processed .
Common blocking patterns. Synchronous file reads (fs.readFileSync()), synchronous cryptographic operations (crypto.pbkdf2Sync()), heavy JSON parsing, large array operations, and CPU-intensive algorithms all block the event loop.
The consequence. A web server that blocks the event loop for 100ms cannot serve any other request during that time. Under load, this leads to degraded throughput and, in severe cases, complete denial of service .
The fix. Move blocking operations off the event loop. File operations use the asynchronous APIs, which delegate to the thread pool. CPU-intensive work uses worker_threads, which provides true parallelism. For operations that must be synchronous, minimize their duration and frequency.
d. setImmediate vs setTimeout
Both schedule callbacks for future execution, but they target different phases and behave differently depending on context .
setTimeout(fn, 0) schedules a callback for the timers phase. It executes after at least 0ms, but the actual delay depends on how long the loop takes to reach the timers phase.
setImmediate(fn) schedules a callback for the check phase. It executes after the current poll phase completes.
In the main module, the order is non-deterministic. It depends on how quickly the process reaches the timers phase versus the check phase. If the loop enters the timers phase before 1ms has elapsed, setImmediate may execute first .
Inside an I/O callback, setImmediate always executes first. The poll phase completes, the check phase runs immediately after, and the timers phase is reached only on the next loop iteration .
e. process.nextTick and microtasks
process.nextTick() schedules a callback that runs after the current operation completes, before the event loop continues to the next phase. It is not part of the event loop; it has its own queue that is processed at every phase boundary .
Use cases. Deferring work until after the current operation, allowing an API to be asynchronous even when it could be synchronous, and ensuring that a callback runs before any I/O events are processed.
The starvation risk. Recursive process.nextTick() calls can starve the event loop. Because the nextTick queue is processed before the loop continues, a callback that schedules another nextTick callback prevents the loop from ever reaching the poll phase. This can freeze the application .
Microtasks. Promise callbacks are microtasks. They are processed after the nextTick queue, also before the event loop continues. The order is: current operation completes → nextTick queue → microtask queue → next event loop phase .
Complete Example Session
// ============================================
// PART 1: SYNCHRONOUS FILE READ BLOCKS THE LOOP
// ============================================
// This blocks all other JavaScript until complete.
const fs = require('fs');
console.log('start');
const data = fs.readFileSync('/etc/hostname', 'utf8');
console.log('sync read complete');
// ============================================
// PART 2: ASYNCHRONOUS FILE READ USES THE POOL
// ============================================
// The worker thread reads the file; the loop continues.
fs.readFile('/etc/hostname', 'utf8', (err, data) => {
console.log('async read complete');
});
console.log('this runs before the read completes');
// ============================================
// PART 3: EVENT LOOP PHASES IN ACTION
// ============================================
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
process.nextTick(() => console.log('nextTick'));
console.log('synchronous');
// Output order:
// synchronous
// nextTick
// timeout or immediate (non-deterministic)
// ============================================
// PART 4: setImmediate BEFORE setTimeout IN I/O
// ============================================
fs.readFile(__filename, () => {
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
});
// Output order (always):
// immediate
// timeout
// ============================================
// PART 5: THREAD POOL SATURATION
// ============================================
// Four threads handle all file operations.
// A fifth operation queues behind them.
const start = Date.now();
for (let i = 0; i < 8; i++) {
fs.readFile(__filename, () => {
console.log(`read ${i}: ${Date.now() - start}ms`);
});
}
// The first four complete quickly; the next four
// wait for a thread to become available.
// ============================================
// PART 6: CHECK THREAD POOL SIZE
// ============================================
console.log(process.env.UV_THREADPOOL_SIZE || 'default: 4');
// ============================================
// PART 7: SET THREAD POOL SIZE (BEFORE START)
// ============================================
// Must be set before the process starts.
// UV_THREADPOOL_SIZE=8 node app.js
// ============================================
// PART 8: DNS LOOKUP USES THE POOL
// ============================================
// dns.lookup uses getaddrinfo, which blocks.
const dns = require('dns');
dns.lookup('example.com', (err, address) => {
console.log('lookup:', address);
});
// ============================================
// PART 9: DNS RESOLVE BYPASSES THE POOL
// ============================================
// dns.resolve uses c-ares, which is asynchronous.
dns.resolve('example.com', (err, addresses) => {
console.log('resolve:', addresses);
});
// ============================================
// PART 10: MOVING CPU WORK TO A WORKER
// ============================================
// worker_threads provides true parallelism.
const { Worker, isMainThread, parentPort } = require('worker_threads');
if (isMainThread) {
const worker = new Worker(__filename);
worker.on('message', (result) => {
console.log('result:', result);
});
} else {
let sum = 0;
for (let i = 0; i < 1e9; i++) {
sum += i;
}
parentPort.postMessage(sum);
}
These ten parts cover the difference between synchronous and asynchronous file reads, event loop phases, setImmediate vs setTimeout, thread pool saturation, thread pool configuration, DNS lookup vs resolve, and moving CPU work to a worker thread.
Quick Reference
Event Loop Phases
| Phase | Purpose |
|---|---|
| Timers | setTimeout(), setInterval() |
| Pending callbacks | System callbacks |
| Poll | I/O events; blocks if idle |
| Check | setImmediate() |
| Close callbacks | Socket close events |
| nextTick / microtasks | Between each phase |
Thread Pool Operations
| Category | Uses Thread Pool |
|---|---|
| File system | Yes (except fs.FSWatcher) |
DNS lookup | Yes (getaddrinfo) |
DNS resolve | No (c-ares) |
| Network I/O | No (non-blocking sockets) |
| Crypto | Yes (pbkdf2, scrypt, randomBytes) |
| zlib | Yes (except sync methods) |
Thread Pool Configuration
| Setting | Value |
|---|---|
| Default size | 4 |
| Maximum | 1024 |
| Environment variable | UV_THREADPOOL_SIZE |
| When to set | Before process starts |
Ordering
| Context | Order |
|---|---|
| Main module | Non-deterministic |
| Inside I/O callback | setImmediate before setTimeout |
| After current operation | nextTick before microtasks |
Best Practices
✅ Do This:
fs.readFile(path, callback); // Async file I/O
dns.resolve('example.com', callback); // Async DNS
new Worker('./heavy.js'); // CPU work off the loop
UV_THREADPOOL_SIZE=8 node app.js // Increase pool
process.nextTick(() => { /* cleanup */ }); // Defer to after current op
❌ Don’t Do This:
fs.readFileSync(path); // ❌ Blocks the loop
dns.lookupSync('example.com'); // ❌ No such method
while (true) { /* heavy compute */ } // ❌ Starves the loop
process.nextTick(recursiveNextTick); // ❌ Starves the loop
UV_THREADPOOL_SIZE=8; // in code // ❌ Must be env var
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Server unresponsive | Blocking the event loop | Use async APIs |
| File reads delayed | Thread pool saturated | Increase UV_THREADPOOL_SIZE |
| DNS lookup slow | Uses getaddrinfo on the pool | Use dns.resolve or increase pool |
| nextTick starvation | Recursive process.nextTick | Use setImmediate for recursion |
| Thread pool size ignored | Set after first I/O operation | Set env var before process starts |
| CPU work blocks everything | Running on the event loop | Use worker_threads |
Real-World Examples
1. Async File Read
const { readFile } = require('fs').promises;
const data = await readFile('data.txt', 'utf8');
2. DNS Resolve Without Pool
const dns = require('dns').promises;
const addresses = await dns.resolve('example.com');
3. Thread Pool Size
UV_THREADPOOL_SIZE=16 node server.js
4. Worker Thread for CPU Work
const { Worker } = require('worker_threads');
const worker = new Worker('./fibonacci.js');
worker.postMessage(40);
worker.on('message', console.log);
5. Defer with nextTick
process.nextTick(() => {
console.log('runs before I/O');
});
6. Schedule with setImmediate
setImmediate(() => {
console.log('runs after poll phase');
});
7. Check Event Loop Utilization
const { monitorEventLoopDelay } = require('perf_hooks');
const h = monitorEventLoopDelay();
h.enable();
setTimeout(() => {
console.log(h.mean);
h.disable();
}, 1000);
8. Avoid Blocking with Streams
fs.createReadStream('large.txt')
.on('data', (chunk) => process(chunk));
9. Parallel File Operations
const files = ['a.txt', 'b.txt', 'c.txt'];
Promise.all(files.map(f => readFile(f, 'utf8')));
10. Check Pool Saturation
// Monitor queue depth with a custom timer
setInterval(() => {
// If file operations are slow, pool may be saturated
}, 1000);
Visual
Event Loop and Thread Pool
┌──────────────────────────────────────────────────────────────┐
│ EVENT LOOP THREAD │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Executes all JavaScript │ │
│ │ Handles network I/O (non-blocking sockets) │ │
│ │ Processes timers, nextTick, microtasks │ │
│ └────────────────────────────────────────────────────────┘ │
│ │ │
│ │ delegates blocking ops │
│ ▼ │
│ THREAD POOL (libuv) │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Worker 1 │ Worker 2 │ Worker 3 │ Worker 4 │ │
│ │ fs.read │ fs.write │ dns │ crypto │ │
│ └────────────────────────────────────────────────────────┘ │
│ │ │
│ │ result back to loop │
│ ▼ │
│ EVENT LOOP THREAD │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Executes the callback with the result │ │
│ └────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
Event Loop Phases
┌──────────────────────────────────────────────────────────────┐
│ ┌─────────┐ │
│ │ TIMERS │ setTimeout, setInterval │
│ └────┬────┘ │
│ ▼ │
│ ┌─────────┐ │
│ │ PENDING │ System callbacks │
│ └────┬────┘ │
│ ▼ │
│ ┌─────────┐ │
│ │ POLL │ I/O events; blocks if idle │
│ └────┬────┘ │
│ ▼ │
│ ┌─────────┐ │
│ │ CHECK │ setImmediate │
│ └────┬────┘ │
│ ▼ │
│ ┌─────────┐ │
│ │ CLOSE │ Socket close events │
│ └─────────┘ │
│ │
│ Between each phase: nextTick queue → microtask queue │
└──────────────────────────────────────────────────────────────┘
Thread Pool Operations
┌──────────────────────────────────────────────────────────────┐
│ USES THREAD POOL BYPASSES THREAD POOL │
│ ───────────────────────────── ───────────────────────── │
│ fs.readFile net.createServer │
│ fs.writeFile http.request │
│ dns.lookup dns.resolve │
│ crypto.pbkdf2 TCP/UDP sockets │
│ zlib.gzip HTTP/HTTPS │
└──────────────────────────────────────────────────────────────┘
Thread Pool Saturation
┌──────────────────────────────────────────────────────────────┐
│ TIME ──▶ │
│ │
│ Worker 1: ████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │
│ Worker 2: ████████████████████░░░░░░░░░░░░░░░░░░░░░░░░ │
│ Worker 3: ░░░░░░░░░░░░░░░░████████████████░░░░░░░░░░░░ │
│ Worker 4: ░░░░░░░░░░░░░░░░░░░░░░░░░░███████████████░ │
│ │
│ Queue: ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░███████░ │
│ │
│ A fifth operation waits for a worker to become available. │
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Event loop | Single thread executing JavaScript and callbacks |
| Thread pool | libuv workers for blocking operations |
| Default pool size | 4 threads |
| Maximum pool size | 1024 |
| Pool configuration | UV_THREADPOOL_SIZE environment variable |
| Pool operations | File system, DNS lookup, crypto, zlib |
| Non-pool operations | Network I/O, dns.resolve |
| Event loop phases | Timers, pending, poll, check, close |
setImmediate | Runs in check phase |
setTimeout | Runs in timers phase |
process.nextTick | Runs before loop continues |
| Blocking risk | Synchronous operations on the event loop |
Key takeaways:
- The event loop is single-threaded and executes all JavaScript. It handles network I/O through non-blocking OS primitives, but it cannot execute JavaScript while any operation is running .
- The libuv thread pool handles blocking operations. File system operations, DNS lookups via
getaddrinfo, certain crypto functions, and zlib compression run on a pool of worker threads . - The pool defaults to 4 threads. It can be increased with
UV_THREADPOOL_SIZEbefore the process starts. Under heavy file I/O, a small pool becomes a bottleneck . - Network I/O bypasses the thread pool. TCP, UDP, and HTTP sockets are handled by the event loop using the OS’s non-blocking event mechanism. This is why a single thread can handle thousands of connections .
dns.lookupuses the pool;dns.resolvedoes not.lookupcallsgetaddrinfo, which is blocking.resolveuses c-ares, which is asynchronous. Under load,lookupcan saturate the pool .- Blocking the event loop stops everything. A synchronous file read, a heavy computation, or a long-running callback prevents all other JavaScript from executing. This is the primary performance pitfall in Node.js .
setImmediateruns in the check phase;setTimeoutruns in the timers phase. Inside an I/O callback,setImmediatealways executes first. In the main module, the order is non-deterministic .process.nextTickruns before the event loop continues. It is not part of any phase; it runs after the current operation completes. RecursivenextTickcalls can starve the loop .
Remember: The event loop and thread pool are the two halves of Node.js’s asynchronous model. The event loop executes JavaScript and handles network I/O using the operating system’s non-blocking facilities. The thread pool handles operations that cannot be made non-blocking—file reads, DNS lookups, crypto, compression—by running them on worker threads and returning results to the event loop. The default pool size of 4 is sufficient for light workloads but becomes a bottleneck under heavy file I/O. The event loop must never be blocked by long-running JavaScript; CPU-bound work belongs in worker_threads. Understanding which operations use the pool and which bypass it is the key to diagnosing performance problems and tuning the runtime for specific workloads.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!