Skip to main content
Navigation
HomeTechnical ReferenceJournalGitHubGitHub
Sidebar — toggle document categories via the logo
Categories

Node.js Event Loop

Overview​

The Node.js event loop is what allows JavaScript — a single-threaded language — to handle thousands of concurrent I/O operations without blocking. Built on libuv (originally developed for Node.js), the event loop orchestrates the execution of callbacks, timers, I/O operations, and microtasks. Understanding its phases is essential for writing performant, non-blocking Node.js applications.

The event loop in 30 seconds​

The event loop is an infinite loop that runs in the main thread:

while (there_are_pending_operations) {
1. Process timers (setTimeout, setInterval that have expired)
2. Process pending callbacks (deferred I/O callbacks)
3. Idle, prepare (internal libuv housekeeping)
4. Poll (block for new I/O events, execute ready I/O callbacks)
5. Check (setImmediate callbacks)
6. Close callbacks (socket.on('close'), etc.)
}

Between each phase transition, Node.js drains the microtask queue (process.nextTick and Promise callbacks).

Event loop phases in detail​

Phase 1: Timers​

Executes callbacks scheduled by setTimeout and setInterval whose timer has expired:

setTimeout(() => {
console.log("timeout fired");
}, 1000);
// After ~1000ms, the callback is queued for the next Timer phase.

setInterval(() => {
console.log("interval");
}, 2000);
// Repeatedly queued every ~2000ms.

Important: the delay is a minimum, not a guarantee. If the event loop is busy processing another phase, the timer callback will be delayed:

const start = Date.now();

setTimeout(() => {
console.log(`Fired after ${Date.now() - start}ms`);
}, 1000);

// Block the event loop for 2 seconds
while (Date.now() - start < 2000) {}
// Output: "Fired after ~2000ms" — the 1s timer was delayed by the blocking loop

Phase 2: Pending callbacks​

Executes I/O callbacks deferred from the previous cycle. Specifically, this is for callbacks that were delayed because the poll phase was too busy — mostly TCP errors from the OS (ECONNREFUSED on connect attempts, for example).

Most I/O callbacks (file reads, network responses) execute in the Poll phase, not here. This phase is a catch-all for deferred error callbacks.

Phase 3: Idle / Prepare​

Internal libuv phases — not directly accessible from JavaScript. Used for preparing the poll phase and handling idle watchers.

Phase 4: Poll​

The most important phase. The poll phase:

  1. Computes how long to block for I/O:
    • If the poll queue has callbacks: timeout = 0 (don't block).
    • If timers are scheduled: timeout = time until the next timer.
    • If nothing is scheduled: timeout = Infinity (block until woken by I/O).
  2. Blocks on the OS I/O multiplexer (epoll on Linux, kqueue on macOS, IOCP on Windows).
  3. Executes ready callbacks from the poll queue.

When a callback is executed, it may queue new callbacks. The poll phase processes them all before returning control to the event loop.

const fs = require("fs");

fs.readFile("data.txt", (err, data) => {
// This callback runs in the Poll phase
console.log("File read complete");
});

Phase 5: Check​

Executes setImmediate callbacks:

setImmediate(() => {
console.log("immediate callback");
});

setImmediate is specifically designed to run immediately after the poll phase, before the next timer phase.

Phase 6: Close callbacks​

Executes close event handlers:

const server = require("net").createServer();
server.on("close", () => {
// This callback runs in the Close phase
console.log("Server closed");
});
server.close();

setImmediate vs setTimeout(fn, 0)​

These are often confused. The key difference:

// Inside an I/O callback, setImmediate always runs first
const fs = require("fs");

fs.readFile(__filename, () => {
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
});
// Output: immediate, timeout

Inside an I/O callback (poll phase), the order is deterministic: setImmediate runs in the next Check phase, setTimeout(fn, 0) runs in the next Timers phase — and Check comes before Timers in the cycle.

Outside I/O callbacks, the order is non-deterministic (depends on system timer resolution):

// At the top level — order varies between runs
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
// Could be either order depending on how long the process took to start

Best practice: wrap in an I/O callback if deterministic ordering matters.

Microtasks: process.nextTick and Promises​

The microtask queue​

Microtasks run between each phase, and within a phase between each callback:

[ Phase N ]
|
├── Execute callback 1
├── Drain microtasks (nextTick, then Promises)
├── Execute callback 2
├── Drain microtasks
├── ...
▼
[ Phase N+1 ]
├── Drain microtasks (before entering the phase)
├── ...

process.nextTick vs Promise​

Both are microtasks, but process.nextTick has higher priority:

Promise.resolve().then(() => console.log("promise 1"));
process.nextTick(() => console.log("nextTick 1"));
Promise.resolve().then(() => console.log("promise 2"));
process.nextTick(() => console.log("nextTick 2"));

// Output:
// nextTick 1
// nextTick 2
// promise 1
// promise 2

Node.js drains all nextTick callbacks before any Promise callbacks.

The nextTick recursion danger​

Because process.nextTick callbacks run before the event loop continues, a recursive nextTick starves the event loop:

// DO NOT DO THIS
function recursiveNextTick() {
process.nextTick(recursiveNextTick);
}
recursiveNextTick();

setTimeout(() => console.log("timer"), 1000); // NEVER RUNS

The event loop is perpetually stuck draining nextTick callbacks and never reaches the Timer phase. Use setImmediate to yield to the event loop:

function recursiveImmediate() {
// This yields between iterations — timers and I/O can be processed
setImmediate(recursiveImmediate);
}

Complete execution order example​

const fs = require("fs");

console.log("1. start");

setTimeout(() => console.log("2. setTimeout"), 0);
setImmediate(() => console.log("3. setImmediate"));

fs.readFile(__filename, () => {
console.log("4. readFile callback");

setTimeout(() => console.log("5. setTimeout inside I/O"), 0);
setImmediate(() => console.log("6. setImmediate inside I/O"));

process.nextTick(() => console.log("7. nextTick inside I/O"));
Promise.resolve().then(() => console.log("8. Promise inside I/O"));
});

process.nextTick(() => console.log("9. nextTick at top level"));
Promise.resolve().then(() => console.log("10. Promise at top level"));

// Output order:
// 1. start
// 9. nextTick at top level
// 10. Promise at top level
// 2. setTimeout OR 3. setImmediate (non-deterministic)
// 3. setImmediate OR 2. setTimeout
// 4. readFile callback
// 7. nextTick inside I/O
// 8. Promise inside I/O
// 6. setImmediate inside I/O
// 5. setTimeout inside I/O

The thread pool (libuv worker pool)​

Node.js uses a thread pool (default: 4 threads) for operations that cannot be done asynchronously at the OS level:

  • All fs filesystem operations (except fs.FSWatcher).
  • dns.lookup() (not dns.resolve() which uses the network directly).
  • Crypto operations (crypto.pbkdf2, crypto.randomBytes, etc.).
  • zlib compression/decompression.

The thread pool size is configurable:

UV_THREADPOOL_SIZE=8 node app.js
// At the top of your entry point
process.env.UV_THREADPOOL_SIZE = "8";

How the thread pool works​

Main Thread (event loop) Thread Pool
│ │
│ fs.readFile() ──────────────►│ Thread 1: reads file
│ │ Thread 2: idle
│ │ Thread 3: idle
│ │ Thread 4: idle
│ │
│ (event loop continues) │
│ │ Thread 1: done —
│ ◄─── callback enqueued ──────│ notifies main thread
│ │
▼ (next Poll phase) ▼
callback runs

Thread pool exhaustion​

If 4 fs.readFile calls are already in the pool, a 5th call waits for a thread:

// 10 concurrent file reads on a 4-thread pool
for (let i = 0; i < 10; i++) {
fs.readFile(`file${i}.txt`, (err, data) => {
console.log(`${i} done at ${Date.now() - start}ms`);
});
}
// First 4 start immediately, 5-10 queue up and run as threads free up

For I/O-heavy applications, increase UV_THREADPOOL_SIZE or consider using async file I/O via fs.promises with O_DIRECT on Linux (libuv cannot fully leverage io_uring yet as of 2025).

Don't block the event loop​

What blocks the event loop​

OperationBlocking?Fix
JSON.parse(largeString)YesOffload via worker thread or stream parsers
crypto.pbkdf2Sync()YesUse async crypto.pbkdf2()
fs.readFileSync()YesUse fs.promises.readFile()
Heavy regex on long stringsYesOffload or use incremental regex
Large synchronous loopsYesSplit into chunks with setImmediate
child_process.execSync()YesUse child_process.exec()

Detecting event loop lag​

const { eventLoopUtilization, performance } = require("perf_hooks");

// Track ELU (Event Loop Utilization) — Node.js 14+
const start = eventLoopUtilization();

setTimeout(() => {
const end = eventLoopUtilization(start);
console.log(`ELU idle: ${(end.idle / 1000).toFixed(2)}µs`);
console.log(`ELU active: ${(end.active / 1000).toFixed(2)}µs`);
console.log(`ELU utilization: ${(end.utilization * 100).toFixed(1)}%`);
}, 5000);

Offloading CPU-bound work with worker_threads​

// main.js
const { Worker } = require("worker_threads");

function runHeavyTask(input) {
return new Promise((resolve, reject) => {
const worker = new Worker("./worker.js", { workerData: input });
worker.on("message", resolve);
worker.on("error", reject);
worker.on("exit", (code) => {
if (code !== 0) reject(new Error(`Worker stopped with exit code ${code}`));
});
});
}

async function main() {
// These run in parallel, each in its own OS thread
const [r1, r2] = await Promise.all([
runHeavyTask(40),
runHeavyTask(41),
]);
console.log(r1, r2);
}
// worker.js
const { parentPort, workerData } = require("worker_threads");

function fibonacci(n) {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}

const result = fibonacci(workerData);
parentPort.postMessage(result);

The cluster module​

For CPU-bound HTTP servers, the cluster module forks multiple processes to utilize all CPU cores:

const cluster = require("cluster");
const http = require("http");
const os = require("os");

if (cluster.isPrimary) {
const numCPUs = os.cpus().length;
console.log(`Primary ${process.pid} running`);

// Fork a worker for each CPU
for (let i = 0; i < numCPUs; i++) {
cluster.fork();
}

cluster.on("exit", (worker) => {
console.log(`Worker ${worker.process.pid} died — restarting`);
cluster.fork(); // auto-restart
});
} else {
// Workers share the same port
http.createServer((req, res) => {
res.writeHead(200);
res.end(`Hello from worker ${process.pid}\n`);
}).listen(8000);

console.log(`Worker ${process.pid} started`);
}

Each worker gets its own event loop, memory space, and V8 instance. Requests are distributed by the OS kernel (on Linux, the SO_REUSEPORT socket option means the kernel does round-robin load balancing automatically).

Event loop best practices​

PracticeWhy
Never block the main threadBlocking starves all other callbacks, timers, and I/O
Use async crypto/filesystem APIsThe *Sync variants block the entire event loop
Limit process.nextTick recursionCan starve the event loop — use setImmediate instead
Keep callbacks fastEach callback delays everything scheduled after it
Watch the thread poolDefault 4 threads can be a bottleneck for I/O-heavy apps
Use worker threads for CPU workKeeps the main event loop responsive
Cluster for multi-core utilizationOne Node.js process uses one CPU core by default
Monitor event loop lagUse APM tools or eventLoopUtilization()

See also​