Event Loop & libuv
The event loop is the runtime construct that lets Node.js perform non-blocking I/O despite JavaScript being single-threaded. It offloads operations to the kernel or a worker thread pool, then resumes your callbacks when those operations complete. Understanding the event loop is essential for writing correct, high-performance Node.js applications.
Node.js delegates most asynchronous work to libuv, a C library that provides the event loop and a thread pool for operations that cannot be done asynchronously by the operating system. The event loop is divided into phases, each with a specific purpose and queue of callbacks.
This guide walks through each phase, explains how timers and I/O are scheduled, compares process.nextTick and queueMicrotask to the loop phases, and shows how to detect and mitigate event loop lag in production.
A single tick of the Node.js event loop consists of six main phases. The loop runs continuously while there is pending work. When a phase has no more callbacks, the loop moves to the next phase.
| Phase | Purpose | Typical Callbacks |
|---|---|---|
| timers | Execute scheduled callbacks | setTimeout, setInterval |
| pending callbacks | Run deferred I/O callbacks | System-specific TCP errors |
| idle, prepare | Internal libuv bookkeeping | Used by libuv only |
| poll | Retrieve new I/O events | fs.readFile, HTTP requests, sockets |
| check | Execute setImmediate callbacks | setImmediate |
| close callbacks | Handle closed connections | socket.on('close', ...) |
Between each phase, Node.js processes microtasks. process.nextTick callbacks run before microtasks from queueMicrotask or resolved promises. Because nextTick runs before the event loop continues, recursive use can starve the loop.
warning
setTimeout(callback, 0) schedules a callback in the timers phase. setImmediate schedules a callback in the check phase. Their relative order depends on context.
| 1 | // Inside an I/O callback, setImmediate runs before setTimeout(0) |
| 2 | const fs = require('fs'); |
| 3 | |
| 4 | fs.readFile(__filename, () => { |
| 5 | setTimeout(() => console.log('timeout'), 0); |
| 6 | setImmediate(() => console.log('immediate')); |
| 7 | }); |
| 8 | |
| 9 | // Output in I/O context: |
| 10 | // immediate |
| 11 | // timeout |
In the main script, the order is non-deterministic because the timers phase may fire before the poll phase reaches the immediate. In an I/O callback, setImmediate always wins because the poll phase transitions directly to check.
| 1 | // Top-level context: order is not guaranteed |
| 2 | setTimeout(() => console.log('timeout'), 0); |
| 3 | setImmediate(() => console.log('immediate')); |
| 4 | |
| 5 | // May print either order depending on process startup time |
info
Promises and queueMicrotask schedule microtasks that run after the current operation and before the next event loop phase. process.nextTick is technically not part of the event loop; it runs at the end of the current operation, before any microtasks.
| 1 | console.log('start'); |
| 2 | |
| 3 | process.nextTick(() => console.log('nextTick 1')); |
| 4 | Promise.resolve().then(() => console.log('promise 1')); |
| 5 | process.nextTick(() => console.log('nextTick 2')); |
| 6 | Promise.resolve().then(() => console.log('promise 2')); |
| 7 | |
| 8 | console.log('end'); |
| 9 | |
| 10 | // Output: |
| 11 | // start |
| 12 | // end |
| 13 | // nextTick 1 |
| 14 | // nextTick 2 |
| 15 | // promise 1 |
| 16 | // promise 2 |
Notice that all nextTick callbacks execute before any promise microtasks. This ordering is guaranteed. In practice, prefer queueMicrotask for deferring work unless you specifically need the higher priority of nextTick.
best practice
Not all I/O in Node.js is truly asynchronous at the operating system level. Network operations use epoll, kqueue, or IOCP and do not consume thread-pool threads. File system operations, DNS lookups, and some cryptographic functions use the libuv thread pool, which has a fixed default size of 4 threads.
| 1 | const crypto = require('crypto'); |
| 2 | const os = require('os'); |
| 3 | |
| 4 | console.log('Thread pool size:', os.availableParallelism()); |
| 5 | // Default is typically 4; you can override it at startup: |
| 6 | // UV_THREADPOOL_SIZE=128 node app.js |
| 7 | |
| 8 | const start = Date.now(); |
| 9 | for (let i = 0; i < 5; i++) { |
| 10 | crypto.pbkdf2('secret', 'salt', 100000, 64, 'sha512', () => { |
| 11 | console.log(`pbkdf2 done in ${Date.now() - start}ms`); |
| 12 | }); |
| 13 | } |
With a thread pool of 4, the fifth pbkdf2 call waits for a thread to free up. This serializes part of the workload and increases latency. For CPU-bound tasks like password hashing, prefer bcrypt or argon2 with configurable work factors, or offload to worker threads.
note
Event loop lag is the delay between scheduling a timer and the event loop actually executing it. High lag indicates that the event loop is blocked by synchronous work. In production, sustained lag above 50 ms degrades response times and can cause health checks to fail.
| 1 | function measureEventLoopLag() { |
| 2 | let last = performance.now(); |
| 3 | |
| 4 | setInterval(() => { |
| 5 | const now = performance.now(); |
| 6 | const lag = now - last - 1000; |
| 7 | console.log(`Event loop lag: ${lag.toFixed(2)}ms`); |
| 8 | last = now; |
| 9 | }, 1000).unref(); |
| 10 | } |
| 11 | |
| 12 | measureEventLoopLag(); |
| 13 | |
| 14 | // Simulate blocking work every 3 seconds |
| 15 | setInterval(() => { |
| 16 | const blockUntil = Date.now() + 500; |
| 17 | while (Date.now() < blockUntil) {} |
| 18 | }, 3000); |
The performance.eventLoopUtilization() API in Node.js 20+ provides a more precise view of how busy the event loop is relative to idle time. Combine it with perf_hooks and application metrics to build dashboards.
| 1 | const { performance } = require('perf_hooks'); |
| 2 | |
| 3 | const start = performance.eventLoopUtilization(); |
| 4 | setInterval(() => { |
| 5 | const elapsed = performance.eventLoopUtilization(start); |
| 6 | console.log('utilization:', (elapsed.utilization * 100).toFixed(2) + '%'); |
| 7 | }, 5000); |
best practice
Mixing blocking and non-blocking operations in the same function is a common source of subtle bugs. The event loop only benefits your application when all I/O is non-blocking and synchronous work is kept short.
| 1 | // WRONG: blocks the event loop |
| 2 | app.get('/data', (req, res) => { |
| 3 | const data = fs.readFileSync('./large-file.json'); |
| 4 | res.json(JSON.parse(data)); |
| 5 | }); |
| 6 | |
| 7 | // RIGHT: uses streams and async I/O |
| 8 | app.get('/data', (req, res) => { |
| 9 | const stream = fs.createReadStream('./large-file.json'); |
| 10 | stream.pipe(res); |
| 11 | }); |
Even small synchronous calls add up under load. Parsing a 1 MB JSON file takes only a few milliseconds, but with thousands of requests per second those milliseconds compound into seconds of event loop latency.
warning
libuv is the C library at the heart of Node.js asynchronous operations. It abstracts platform-specific event notification mechanisms into a unified cross-platform API. On Linux it uses epoll, on macOS kqueue, and on Windows IOCP.
| Operation | Mechanism | Blocks Thread Pool? |
|---|---|---|
| TCP/UDP sockets | Kernel event notification | No |
| File system | Thread pool | Yes |
| DNS lookup | Thread pool (getaddrinfo) | Yes |
| Crypto pbkdf2 | Thread pool | Yes |
| Child process spawn | Platform-specific | Partially |
pro tip