
Node.js runs JavaScript on a single thread, yet powers some of the busiest servers on the planet. The secret is the event loop — a coordination mechanism that lets a single thread juggle thousands of concurrent I/O operations by never blocking to wait for results. Understanding it deeply is what separates developers who write performant Node.js from those who accidentally block everything.
The event loop is not part of V8 (the JavaScript engine). It lives in libuv,
a C library that provides Node.js with cross-platform asynchronous I/O, thread
pools, and the event loop itself. When you call fs.readFile(), libuv offloads
the work to its thread pool (4 threads by default), and notifies the event loop
when the OS signals completion. Your JavaScript thread never blocks.
The event loop runs continuously in a cycle. Each iteration (a "tick") walks through six ordered phases:
┌──────────────────────────────────────────────────┐
│ timers │ setTimeout, setInterval callbacks
└─────────────────────────┬────────────────────────┘
│
┌─────────────────────────▼────────────────────────┐
│ pending callbacks │ I/O errors from previous tick
└─────────────────────────┬────────────────────────┘
│
┌─────────────────────────▼────────────────────────┐
│ idle, prepare │ internal use only
└─────────────────────────┬────────────────────────┘
│
┌─────────────────────────▼────────────────────────┐
│ poll │ fetch new I/O events (blocking here)
└─────────────────────────┬────────────────────────┘
│
┌─────────────────────────▼────────────────────────┐
│ check │ setImmediate callbacks
└─────────────────────────┬────────────────────────┘
│
┌─────────────────────────▼────────────────────────┐
│ close callbacks │ socket.on('close', ...)
└──────────────────────────────────────────────────┘
Executes callbacks whose setTimeout() or setInterval() delay has elapsed.
Note: the delay is a minimum, not a guarantee — if the poll phase is busy,
timers fire late.
The most important phase. The event loop sits here waiting for I/O events if no callbacks are ready. When a file read, network response, or database query completes, the OS signals libuv, and the poll queue fills with callbacks to execute.
Executes setImmediate() callbacks — these always run after the poll phase,
even if a timer is eligible.
Between every phase transition, Node.js drains two special queues before moving on:
process.nextTick() queue — highest priorityPromise microtask queue (.then(), async/await)setTimeout(() => console.log("setTimeout"), 0);
setImmediate(() => console.log("setImmediate"));
Promise.resolve().then(() => console.log("Promise.then"));
process.nextTick(() => console.log("nextTick"));
console.log("synchronous");
Output:
synchronous
nextTick
Promise.then
setTimeout (or setImmediate — order between these is non-deterministic in the main module)
setImmediate
nextTick fires before Promises because it has its own higher-priority queue.
Both fire before any macrotask (setTimeout, setImmediate, I/O).
Because there is only one JS thread, any synchronous, CPU-intensive code blocks all incoming requests until it finishes:
// ❌ This blocks for ~1 second — every pending request waits
app.get("/bad", (req, res) => {
const start = Date.now();
while (Date.now() - start < 1000) {} // busy-wait
res.send("done");
});
// ✅ Non-blocking — yields control back to the event loop
app.get("/good", async (req, res) => {
await new Promise((resolve) => setTimeout(resolve, 1000));
res.send("done");
});
Common accidental blocking sources: JSON.parse() on huge payloads, synchronous
crypto, fs.readFileSync(), and heavy regex on untrusted input.
For genuinely CPU-bound tasks (image resizing, zip compression, ML inference), offload to a Worker Thread:
import { Worker, isMainThread, parentPort, workerData } from "worker_threads";
if (isMainThread) {
function runCpuTask(data) {
return new Promise((resolve, reject) => {
const worker = new Worker(new URL(import.meta.url), { workerData: data });
worker.on("message", resolve);
worker.on("error", reject);
});
}
const result = await runCpuTask({ numbers: [1, 2, 3, 4, 5] });
console.log("Result:", result);
} else {
// This code runs in the worker thread
const sum = workerData.numbers.reduce((a, b) => a + b, 0);
parentPort.postMessage(sum);
}
Worker Threads share memory (via SharedArrayBuffer) but have their own event
loops, keeping the main thread free.
Certain built-in operations — fs, dns.lookup(), crypto — run in libuv's
thread pool (default size: 4). You can tune it:
UV_THREADPOOL_SIZE=16 node server.js
If 4 concurrent DNS lookups are in flight and a 5th comes in, it queues. This is a common hidden bottleneck in services that do heavy database or DNS work.
async/await is syntactic sugar over Promises. Every await suspends the
function and schedules the continuation as a microtask:
async function fetchUser(id) {
console.log("A"); // synchronous
const user = await db.findById(id); // suspends, event loop continues
console.log("B"); // microtask — runs after current phase drains
return user;
}
fetchUser(1);
console.log("C"); // runs before "B" — synchronous code always runs first
// Output: A, C, B
| Rule | Why |
|---|---|
Never use *Sync APIs in request handlers | Blocks the entire server |
| Keep synchronous code per request under ~1ms | Ensures timers stay accurate |
Tune UV_THREADPOOL_SIZE for I/O-heavy apps | Prevents pool starvation |
Use setImmediate instead of setTimeout(fn, 0) | More predictable, skips timer phase overhead |
| Offload CPU work to Worker Threads | Preserves main thread responsiveness |
The event loop is not magic — it is a disciplined scheduling algorithm that trades multi-threading complexity for a simpler concurrency model. When you understand its phases and the microtask priority queue, you can predict exactly when your callbacks fire, avoid blocking bugs before they ship, and write Node.js services that genuinely scale.