The event loop is the mechanism that makes Node.js capable of handling thousands of simultaneous connections despite running on a single thread. Understanding it deeply — not just “Node.js is asynchronous” — lets you predict code execution order, avoid subtle bugs, write efficient servers, and explain confidently why a blocking operation in the wrong place can bring a Node.js server to a halt. This lesson is a Node.js-specific deep dive into the event loop phases, the libuv thread pool, and the difference between the Node.js and browser event loop models.
The Node.js Event Loop Phases
| Phase | Queue | Processes |
|---|---|---|
| 1 — timers | Timer callbacks | setTimeout and setInterval callbacks whose delay has expired |
| 2 — pending callbacks | System callbacks | I/O callbacks deferred from the previous loop iteration (TCP errors etc.) |
| 3 — idle / prepare | Internal | Internal Node.js use only |
| 4 — poll | I/O callbacks | Retrieve new I/O events; execute I/O-related callbacks (fs, net, http) |
| 5 — check | setImmediate | setImmediate callbacks |
| 6 — close callbacks | Close events | Socket and handle close events (socket.on('close', ...)) |
Microtask Queues — Run Between Every Phase
| Queue | Source | Priority |
|---|---|---|
| nextTick queue | process.nextTick(fn) |
Highest — drains before any other queue |
| Promise microtask queue | Promise.then/catch/finally, queueMicrotask |
Second — drains after nextTick queue |
libuv Thread Pool
| Feature | Detail |
|---|---|
| Default size | 4 threads (set via UV_THREADPOOL_SIZE env var, max 128) |
| Used by | File system operations, DNS resolution, crypto operations, zlib |
| Not used by | Network I/O (TCP/UDP) — handled directly by OS async syscalls |
| Implication | More than 4 concurrent file operations queue and wait for a thread |
process.nextTick() is NOT part of the event loop phases — it runs after the current operation completes and before the event loop continues to the next phase, draining completely on every boundary. This makes it higher priority than Promises. Overusing process.nextTick() with recursive calls can starve the event loop just like an infinite microtask loop. Use it sparingly — prefer Promise.resolve().then() or queueMicrotask() for most async-but-immediate patterns.UV_THREADPOOL_SIZE=16 node server.js or set it in code before any I/O: process.env.UV_THREADPOOL_SIZE = 16. For network I/O (HTTP requests, TCP connections, MongoDB queries), the thread pool is not used at all — the OS handles it natively.setTimeout(fn, 0) does not mean “run immediately” in Node.js either. Timers in Node.js have a minimum resolution of approximately 1ms, and the timer phase only runs callbacks whose delay has actually expired. In I/O callbacks, setImmediate is more reliable than setTimeout(fn, 0) for deferring to the next iteration because setImmediate always runs in the check phase of the current loop iteration, while a timer with delay 0 may fire in the timers phase of the next iteration.Event Loop Execution Order — Complete Example
// event-loop-order.js — run with: node event-loop-order.js
const fs = require('fs');
console.log('1 — synchronous start');
// nextTick — runs before any I/O callbacks, before Promises
process.nextTick(() => console.log('2 — process.nextTick'));
// Promise microtask — runs after nextTick
Promise.resolve().then(() => console.log('3 — Promise.resolve().then'));
// setTimeout with 0 delay — timer phase
setTimeout(() => console.log('6 — setTimeout(0)'), 0);
// setImmediate — check phase (after poll)
setImmediate(() => console.log('5 — setImmediate'));
// File I/O — poll phase callback
fs.readFile(__filename, () => {
console.log('4 — fs.readFile callback (poll phase)');
// Inside an I/O callback, setImmediate runs BEFORE setTimeout
setTimeout(() => console.log('8 — setTimeout inside I/O'), 0);
setImmediate(() => console.log('7 — setImmediate inside I/O')); // guaranteed first
});
console.log('1b — synchronous end');
// OUTPUT ORDER:
// 1 — synchronous start
// 1b — synchronous end
// 2 — process.nextTick
// 3 — Promise.resolve().then
// 4 — fs.readFile callback (poll phase) ← may appear before 5/6
// 5 — setImmediate ← order vs setTimeout varies outside I/O
// 6 — setTimeout(0)
// 7 — setImmediate inside I/O ← guaranteed before setTimeout inside I/O
// 8 — setTimeout inside I/O
Non-Blocking I/O in Practice
// non-blocking-demo.js
const fs = require('fs');
const http = require('http');
// ── Blocking vs Non-Blocking File Read ───────────────────────────────────
// BLOCKING — synchronous — DO NOT USE IN SERVERS
console.time('sync read');
const data = fs.readFileSync('./large-file.txt', 'utf8'); // blocks entire thread
console.timeEnd('sync read');
// NON-BLOCKING — asynchronous — CORRECT for servers
console.time('async read start');
fs.readFile('./large-file.txt', 'utf8', (err, data) => {
if (err) throw err;
console.log('File read complete:', data.length, 'chars');
});
console.timeEnd('async read start'); // logs almost instantly — before file is read
console.log('This runs while the file is being read');
// ── Promise-based async (modern style) ───────────────────────────────────
async function readConfig() {
const data = await fs.promises.readFile('./config.json', 'utf8');
return JSON.parse(data);
}
// ── Parallel I/O — run multiple async operations simultaneously ───────────
async function loadMultipleFiles() {
console.time('parallel');
const [file1, file2, file3] = await Promise.all([
fs.promises.readFile('./a.txt', 'utf8'),
fs.promises.readFile('./b.txt', 'utf8'),
fs.promises.readFile('./c.txt', 'utf8'),
]);
console.timeEnd('parallel'); // completes in max(a, b, c) time, not a + b + c
return [file1, file2, file3];
}
// ── Demonstrating the single thread — never block it ─────────────────────
const server = http.createServer((req, res) => {
if (req.url === '/fast') {
res.end('Fast response'); // returns immediately
}
if (req.url === '/blocking') {
// Simulate CPU-heavy work — THIS BLOCKS ALL OTHER REQUESTS
const start = Date.now();
while (Date.now() - start < 2000) {} // busy-wait 2 seconds
res.end('Slow response — blocked everyone');
}
if (req.url === '/nonblocking') {
// Correct: delegate to setTimeout to yield control
setTimeout(() => res.end('Non-blocking response'), 2000);
// Event loop remains free during the 2-second wait
}
});
server.listen(3000);
How It Works
Step 1 — Synchronous Code Runs First, Always
Every time Node.js starts a script or processes an event, it runs all synchronous code first — from top to bottom, function calls and all — until the call stack is empty. Only then does it check the microtask queues and then begin the next event loop phase. No async callback, timer, or I/O completion can interrupt synchronous code in progress.
Step 2 — nextTick and Promises Drain Between Every Phase
After each event loop phase completes (and after each individual callback within a phase in Node.js 11+), Node.js drains the nextTick queue completely, then drains the Promise microtask queue completely, before moving to the next phase. This means nextTick callbacks added inside a nextTick callback run before any Promises, and Promises added during their own callbacks keep running until the queue is empty.
Step 3 — The Poll Phase Is the Heart of I/O
The poll phase is where most of the work happens. Node.js asks the OS for any completed I/O events, then executes their callbacks. If there is nothing to process and there are setImmediate callbacks waiting, it moves on immediately. If the poll queue is empty and there are no immediate callbacks, it blocks here waiting for I/O — this is how Node.js efficiently sleeps between requests without busy-waiting.
Step 4 — setImmediate vs setTimeout(fn, 0)
Outside an I/O context, the order of setImmediate vs setTimeout(fn, 0) is non-deterministic — it depends on the performance of the machine. Inside an I/O callback (inside a file read or network callback), setImmediate always runs before setTimeout(fn, 0) because setImmediate belongs to the check phase which immediately follows the poll phase where the I/O callback is running.
Step 5 — libuv Thread Pool Handles File System Operations
Network I/O (HTTP requests, TCP connections, MongoDB) goes directly through the OS’s async I/O mechanisms (epoll/kqueue). These do not use threads — the OS notifies libuv when data is available. File system operations are different — most OS file APIs are synchronous at the system level, so libuv uses a thread pool to run file operations in background threads without blocking the JavaScript thread.
Real-World Example: Server Concurrency Test
// concurrency-demo.js
// Start this server and hit /fast and /blocking simultaneously in Postman
// to observe how blocking affects ALL concurrent requests
const http = require('http');
let requestCount = 0;
const server = http.createServer(async (req, res) => {
const id = ++requestCount;
const url = req.url;
const start = Date.now();
console.log(`[${id}] ${url} — received`);
if (url === '/fast') {
// Non-blocking: each request handled immediately
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ id, url, time: Date.now() - start }));
} else if (url === '/async-delay') {
// Non-blocking: event loop is FREE during the 2s wait
await new Promise(resolve => setTimeout(resolve, 2000));
res.writeHead(200);
res.end(JSON.stringify({ id, url, time: Date.now() - start }));
} else if (url === '/blocking') {
// BLOCKING: event loop FROZEN for 2 seconds — all other requests stall
const end = Date.now() + 2000;
while (Date.now() < end) {} // busy-wait
res.writeHead(200);
res.end(JSON.stringify({ id, url, time: Date.now() - start }));
}
console.log(`[${id}] ${url} — completed in ${Date.now() - start}ms`);
});
server.listen(3000, () => console.log('Listening on port 3000'));
// Test with: open two browser tabs simultaneously
// Tab 1: http://localhost:3000/blocking
// Tab 2: http://localhost:3000/fast
// Observe: Tab 2 (fast) waits for Tab 1 (blocking) to complete!
// vs:
// Tab 1: http://localhost:3000/async-delay
// Tab 2: http://localhost:3000/fast
// Observe: Tab 2 responds immediately even while Tab 1 is waiting
Common Mistakes
Mistake 1 — Recursive process.nextTick causing infinite loop
❌ Wrong — starves the event loop completely:
function recursiveNextTick() {
process.nextTick(recursiveNextTick); // I/O and timers never get to run
}
recursiveNextTick();
✅ Correct — use setImmediate for recursive async patterns to yield to I/O:
function processItems(items, index = 0) {
if (index >= items.length) return;
process(items[index]);
setImmediate(() => processItems(items, index + 1)); // yields to I/O each iteration
}
Mistake 2 — Assuming setTimeout(fn, 0) fires immediately after current code
❌ Wrong assumption in Node.js (and browsers):
let value = 0;
setTimeout(() => console.log(value), 0); // assumes this runs "right after"
value = 42;
// logs 42 — the assignment runs first (synchronous)
// but this is because sync code runs to completion — NOT because setTimeout is immediate
✅ Understand: setTimeout fires in the timers phase, never interrupting synchronous code.
Mistake 3 — Not increasing UV_THREADPOOL_SIZE for file-heavy apps
❌ Wrong — default pool of 4 threads bottlenecks a server doing many concurrent file reads:
// 10 concurrent file reads — only 4 run simultaneously, 6 queue and wait
const reads = Array.from({ length: 10 }, (_, i) =>
fs.promises.readFile(`./file-${i}.txt`)
);
await Promise.all(reads); // takes ~3x longer than optimal
✅ Correct — increase the pool before any I/O code runs:
// At the very top of your entry point, before any requires
process.env.UV_THREADPOOL_SIZE = '16';
// Now 10 concurrent file reads all run simultaneously
Quick Reference
| API | Phase / Queue | Use When |
|---|---|---|
process.nextTick(fn) |
nextTick queue — before I/O | Must run before any I/O callbacks in current tick |
Promise.resolve().then(fn) |
Microtask queue — after nextTick | Standard async-but-immediate pattern |
setImmediate(fn) |
Check phase — after I/O | Defer to next loop iteration without timer delay |
setTimeout(fn, 0) |
Timers phase — next iteration | Minimum delay — less predictable than setImmediate |
fs.readFile(path, cb) |
libuv thread pool → poll phase | All file system async operations |
http.request() |
OS async I/O → poll phase | All network I/O — no thread pool used |