Node.js runs your JavaScript on a single thread, yet a well-written Node server can handle tens of thousands of concurrent connections without breaking a sweat. The event loop is the mechanism that makes this possible — it’s what lets Node stay non-blocking while doing exactly one thing at a time. Understanding it isn’t academic: it’s the difference between a server that scales gracefully and one that mysteriously stalls the moment real traffic hits it.
The Call Stack, the APIs, and the Queues
JavaScript itself only has a call stack — a single-threaded, synchronous execution context. Node extends this with APIs provided by libuv (timers, file I/O, network I/O, DNS) that run outside the JavaScript thread entirely. When you call fs.readFile(), Node hands the actual file-reading work to libuv’s thread pool or the OS kernel, immediately returns control to your code, and queues your callback to run later — once the call stack is empty.
console.log('1: start');
fs.readFile('data.txt', () => {
console.log('3: file read complete');
});
console.log('2: end');
1: start
2: end
3: file read complete
Nothing about this is magic — it’s the entire premise of non-blocking I/O. The read happens off-thread; your callback waits in a queue until the event loop gets around to it.
The Phases of the Event Loop
Each iteration of the event loop — a “tick” — moves through a fixed sequence of phases, each with its own callback queue:
| Phase | Runs |
|---|---|
| timers | Callbacks scheduled by setTimeout() and setInterval() whose threshold has elapsed |
| pending callbacks | I/O callbacks deferred from the previous cycle (e.g. certain TCP errors) |
| poll | Retrieves new I/O events; executes I/O-related callbacks (almost everything lives here) |
| check | Callbacks scheduled with setImmediate() |
| close callbacks | Cleanup, e.g. socket.on('close', ...) |
The loop cycles through these phases in order, repeatedly, for the lifetime of the process. If a phase’s queue is empty and there’s nothing left to poll for, Node exits — which is why a script with no pending timers, open sockets, or listeners simply finishes.
Microtasks vs. Macrotasks
Timers, I/O callbacks, and setImmediate() are macrotasks — one runs per loop iteration per phase. Promises and process.nextTick() are microtasks, and they behave differently: the entire microtask queue drains completely before the event loop moves to the next phase, not just one at a time.
setTimeout(() => console.log('timeout'), 0);
Promise.resolve().then(() => console.log('promise'));
process.nextTick(() => console.log('nextTick'));
console.log('sync');
sync
nextTick
promise
timeout
process.nextTick() queue drains before the Promise microtask queue, and both drain completely before Node even considers moving on to timers. This ordering is a common source of subtle bugs — a chain of .then() calls that keeps scheduling more microtasks can, in principle, starve timers and I/O from ever running.
setTimeout vs. setImmediate
Both schedule a callback for “later,” but which one runs first depends on context:
setTimeout(() => console.log('setTimeout'), 0);
setImmediate(() => console.log('setImmediate'));
At the top level, the order between these two is not guaranteed — it depends on process startup timing. Inside an I/O callback, however, the order is always deterministic:
fs.readFile(__filename, () => {
setTimeout(() => console.log('setTimeout'), 0);
setImmediate(() => console.log('setImmediate'));
});
setImmediate
setTimeout
Since the I/O callback already runs during the poll phase, the loop moves next to check (setImmediate) before it cycles back around to timers (setTimeout) — setImmediate always wins when scheduled from inside I/O.
Blocking the Event Loop
The event loop is single-threaded, so any synchronous work that takes a long time blocks everything else — every other request, every other timer, every other socket — until it finishes.
app.get('/report', (req, res) => {
// Blocks the entire process for every other request in flight
const result = computeExpensiveReportSync(data);
res.json(result);
});
One slow synchronous route doesn’t just slow itself down — it freezes the whole server for every concurrent user, because there’s only one thread to go around. This is the single most common source of “randomly slow” Node APIs in production, and it rarely shows up in local testing where concurrency is low.
Worker Threads for CPU-Bound Work
I/O-bound work (database queries, HTTP calls, file reads) is what Node is built for — it’s non-blocking by nature. CPU-bound work (image processing, cryptography, large computations) is not, and belongs on a separate thread:
// worker.js
const { parentPort, workerData } = require('node:worker_threads');
parentPort.postMessage(computeExpensiveReportSync(workerData));
// main.js
const { Worker } = require('node:worker_threads');
app.get('/report', (req, res) => {
const worker = new Worker('./worker.js', { workerData: data });
worker.once('message', (result) => res.json(result));
worker.once('error', (err) => res.status(500).json({ error: err.message }));
});
The main event loop stays free to handle other requests while the worker thread — which has its own V8 instance and its own call stack — grinds through the expensive computation in parallel.
Monitoring Event Loop Lag
You can’t fix what you can’t measure. Node’s perf_hooks module exposes a monitor purpose-built for tracking how long the loop is spending stuck outside its normal cycle:
const { monitorEventLoopDelay } = require('node:perf_hooks');
const histogram = monitorEventLoopDelay({ resolution: 20 });
histogram.enable();
setInterval(() => {
console.log('p50:', histogram.percentile(50) / 1e6, 'ms');
console.log('p99:', histogram.percentile(99) / 1e6, 'ms');
histogram.reset();
}, 10_000);
A healthy server keeps p99 event loop lag in the low single-digit milliseconds. Anything climbing into the hundreds of milliseconds means something synchronous and expensive is running on the main thread.
Tools like Clinic.js Doctor and
0xflame graphs are worth reaching for the moment lag shows up in monitoring — they pinpoint the exact function blocking the loop instead of leaving you to guess from application logs.
Checklist for Event-Loop-Friendly Code
- No synchronous file, crypto, or compression calls (
readFileSync,execSync) on a hot request path - CPU-bound work (image processing, large JSON parsing, cryptographic hashing) is offloaded to worker threads or a separate service
- Long
.then()chains and recursiveprocess.nextTick()calls are checked for runaway microtask queues - Event loop delay is monitored in production, not just CPU and memory
- Database and HTTP client calls always use their async APIs, never a sync variant
The event loop is the reason Node scales the way it does — and the reason it stops scaling the moment blocking code sneaks onto the main thread. Keep CPU-heavy work off of it, and the non-blocking model does exactly what it promises.

