Node.js Event Loop
Source: The NodeBook — Node.js Event Loop Explained
Related: Browser Event Loop · Browser vs Node.js Event Loop · Event Loop Interview Q&A
One-line mental model
Node.js runs JavaScript on one main thread, delegates waiting work to the operating system or libuv, and executes completed callbacks when the call stack becomes available.
The event loop is not one large callback queue. It is a structured set of libuv phases, combined with higher-priority queues managed by Node.js and V8.
The layers involved
| Layer | Responsibility |
|---|---|
| V8 | Executes JavaScript, owns the call stack and Promise microtask queue, and manages memory |
| Node.js APIs and C++ bindings | Connect JavaScript APIs such as fs.readFile() to native capabilities |
| libuv | Runs the native event loop, tracks timers and I/O, and manages the worker pool |
| Operating system | Reports when sockets and other supported I/O resources are ready |
V8 does not provide file-system, networking, or timer APIs. Those are supplied by Node.js.
Run-to-completion
JavaScript currently on the call stack runs until it returns or throws. A ready timer, Promise callback, or network callback cannot interrupt it.
JavaScript callback starts
↓
Runs until completion
↓
High-priority queues are drained
↓
Event loop continuesThis is what blocking the event loop means: JavaScript occupies the main thread for long enough that everything else must wait.
Common blockers include:
- CPU-heavy loops,
- synchronous file-system or crypto APIs,
- parsing or serializing very large JSON payloads,
- expensive regular expressions,
- long garbage-collection pauses.
Asynchronous I/O prevents waiting from blocking JavaScript. It does not make CPU-heavy JavaScript non-blocking.
How asynchronous work returns
Consider fs.readFile():
- JavaScript calls the Node.js API.
- Node’s bindings translate the request into native work.
- libuv sends the file-system operation to its worker pool.
- A worker reads the file while the main JavaScript thread remains available.
- Completion is reported to the event loop.
- Node eventually invokes the JavaScript callback when the stack is free.
Network I/O usually relies directly on operating-system readiness mechanisms rather than the worker pool.
Event loop phases
A simplified libuv iteration contains:
timers
↓
pending callbacks
↓
idle / prepare
↓
poll
↓
check
↓
close callbacksAn iteration is not a fixed duration. It can be short or long depending on callback work and how long the loop waits for I/O.
Timers
The timers phase runs eligible callbacks from:
setTimeout()setInterval()
A timer delay is a minimum threshold, not an execution guarantee.
setTimeout(callback, 100);This means the callback cannot become eligible before roughly 100 milliseconds. It may run later if JavaScript is blocking or other work is ahead of it.
Starting with Node.js 20 and libuv 1.45, timer processing occurs after the poll phase rather than both before and after it. This can affect edge cases involving timers and setImmediate().
Pending callbacks and internal phases
The pending-callbacks phase runs certain deferred system callbacks, such as some Transmission Control Protocol (TCP) errors.
The idle and prepare phases are internal libuv bookkeeping phases and are not directly exposed as JavaScript scheduling APIs.
Poll
The poll phase is the center of I/O processing. It:
- checks for completed I/O,
- runs many I/O callbacks,
- determines how long it can wait for new I/O,
- stops waiting when an immediate or timer needs attention.
Waiting inside the operating system is efficient. Node is not continuously running a JavaScript loop and wasting the Central Processing Unit (CPU).
Check
The check phase runs callbacks scheduled with:
setImmediate(callback);It is useful for deferring follow-up work until after the current I/O callback and its high-priority queues have completed.
setImmediate() is an in-process scheduling tool—not a durable background job system. Critical work that needs retries or crash recovery belongs in a persistent queue.
Close callbacks
This phase handles some close events, such as a socket’s "close" callback after it is abruptly destroyed.
High-priority queues
The libuv phases are only part of the execution-order story. Node also processes two high-priority queues at JavaScript callback return points:
- The Node.js
process.nextTick()queue - The V8 microtask queue used by Promises and
queueMicrotask()
For CommonJS top-level code and normal phase callbacks, the practical order is:
Current JavaScript
↓
process.nextTick() queue
↓
Promise / queueMicrotask() queue
↓
Next callback or event-loop phaseThese queues are drained after callbacks, not only once at the end of a complete event-loop iteration.
process.nextTick()
Despite its name, process.nextTick() runs before Node continues to the next event-loop phase.
Recursive next-tick scheduling can starve I/O and timers because Node keeps emptying this queue before moving forward.
Use it sparingly. For general microtask scheduling, queueMicrotask() is usually clearer and more portable.
Promise microtasks
Promise handlers and continuations after await run through V8’s microtask queue.
Promise.resolve().then(callback);
queueMicrotask(callback);Microtasks can also starve the event loop if each one continually schedules another.
ECMAScript Module nuance
Top-level ECMAScript Module (ESM) evaluation itself participates in V8’s asynchronous module machinery. This can make top-level ordering between Promise microtasks and process.nextTick() differ from equivalent CommonJS code.
Avoid designing application correctness around subtle top-level scheduling differences.
setTimeout(..., 0) vs setImmediate()
The answer to “which runs first?” depends on where they are scheduled.
From the main script
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));Do not rely on a fixed order. Startup timing and whether the timer is already eligible can affect the result.
From an I/O callback
import fs from "node:fs";
fs.readFile(import.meta.filename, () => {
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
});Here, setImmediate() runs first:
- The file callback runs from an I/O path associated with poll.
- The next phase is check, where the immediate runs.
- The timer waits for timer processing in a later iteration.
Use these APIs for their scheduling semantics—not as a race whose winner controls business logic.
The libuv worker pool
Some asynchronous Node.js APIs use libuv’s shared worker pool because the operating system does not expose a suitable non-blocking interface.
Typical worker-pool users include:
- file-system APIs,
dns.lookup(),- selected crypto operations,
- compression through zlib.
The default pool has four threads. Unrelated work can therefore compete for the same limited resource—for example, slow file reads can delay password hashing.
Increasing UV_THREADPOOL_SIZE may help some workloads, but it also increases memory use and scheduling overhead. Measure worker-pool pressure before changing it.
The libuv worker pool does not execute ordinary JavaScript callbacks in parallel. It performs selected native work and reports completion back to the main event loop.
CPU-bound work
There are two broad options for large CPU workloads.
Partition the work
Split work into bounded chunks and yield between them:
function processChunk() {
doSmallAmountOfWork();
if (hasMoreWork()) {
setImmediate(processChunk);
}
}This improves responsiveness by letting other callbacks run between chunks, but it does not reduce the total amount of computation.
Use worker threads
For true parallel JavaScript execution, use node:worker_threads.
Each worker has:
- its own operating-system thread,
- its own V8 isolate,
- its own event loop,
- separate JavaScript memory by default.
Prefer message passing. For repeated tasks, use a persistent worker pool rather than creating a new worker for every request.
Do not confuse worker threads with:
- the libuv worker pool, which handles selected native operations,
cluster, which creates separate Node.js processes to scale a server across CPU cores.
Why the process stays alive
After the main script finishes, Node keeps running while referenced work remains, such as:
- an open server or socket,
- an active timer,
- a pending request,
- a referenced worker.
When no referenced handles, requests, timers, immediates, or workers remain, the process exits.
Measuring event-loop health
Execution-order knowledge does not tell you whether the event loop is healthy in production. Measure it.
Useful signals include:
- event-loop delay: how late scheduled callbacks run,
- event-loop utilization: how much time the loop spends active rather than idle,
- CPU profiles and flame graphs,
- tail latency,
- worker-pool saturation,
- garbage-collection pauses.
Node provides:
import {
monitorEventLoopDelay,
performance,
} from "node:perf_hooks";
const histogram = monitorEventLoopDelay({ resolution: 10 });
histogram.enable();
console.log(performance.eventLoopUtilization());Use async_hooks only for targeted diagnostics or through observability tooling; it adds complexity and runtime overhead.
Staff-level takeaways
- “Single-threaded” describes ordinary JavaScript execution, not the entire Node.js runtime.
- Event-driven concurrency works only when callbacks return quickly.
- Timers specify earliest eligibility, not exact execution time.
process.nextTick()and microtasks can starve phase callbacks.- Asynchronous APIs can still queue behind the shared libuv worker pool.
- CPU-heavy JavaScript belongs in bounded chunks or worker threads.
- Treat event-loop delay and utilization as production health signals.
- Control execution order explicitly instead of relying on timing accidents.
Interview answer
Node’s event loop coordinates JavaScript execution with native asynchronous work. JavaScript runs to completion on the main thread. Node APIs delegate I/O to the operating system or libuv, and completed callbacks are processed through phases such as timers, poll, check, and close. At every JavaScript callback boundary, Node drains
process.nextTick()first and then V8’s Promise microtasks before continuing through the phases. This model provides excellent I/O concurrency, but CPU-heavy JavaScript, synchronous APIs, excessive microtasks, or a saturated libuv worker pool can still create latency. I measure event-loop delay and utilization, and move substantial CPU work to worker threads.