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

LayerResponsibility
V8Executes JavaScript, owns the call stack and Promise microtask queue, and manages memory
Node.js APIs and C++ bindingsConnect JavaScript APIs such as fs.readFile() to native capabilities
libuvRuns the native event loop, tracks timers and I/O, and manages the worker pool
Operating systemReports 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 continues

This 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():

  1. JavaScript calls the Node.js API.
  2. Node’s bindings translate the request into native work.
  3. libuv sends the file-system operation to its worker pool.
  4. A worker reads the file while the main JavaScript thread remains available.
  5. Completion is reported to the event loop.
  6. 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 callbacks

An 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:

  1. The Node.js process.nextTick() queue
  2. 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 phase

These 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:

  1. The file callback runs from an I/O path associated with poll.
  2. The next phase is check, where the immediate runs.
  3. 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.

Reference