What the Node.js event loop is: one JavaScript thread plus libuv

A Node.js process runs your application code on one JavaScript thread. Every route handler, JSON parse, template render and promise continuation executes there, one at a time. Underneath, libuv provides the event loop and a small worker threadpool (four threads by default, configurable through the UV_THREADPOOL_SIZE environment variable). The loop waits for work to become ready, then hands the matching callback back to the JavaScript thread.

Where the waiting happens depends on the operation:

  • Network I/O (TCP sockets, HTTP servers and clients) uses the operating system's readiness mechanisms: epoll on Linux, kqueue on macOS and the BSDs, and IOCP on Windows. No thread is held per connection, so thousands of idle sockets cost little.
  • Threadpool work covers operations without a usable non-blocking OS interface, or that are CPU-heavy. This includes most fs operations, dns.lookup, and the asynchronous forms of certain crypto functions (such as pbkdf2 and scrypt) and zlib compression.

This model makes the JavaScript thread a single resource shared by every in-flight request. While one request's callback runs, no other request makes progress. Async Node.js therefore fits services that spend most of their time waiting on databases, caches and upstream APIs. If one handler holds the thread for 50 ms, every concurrent request waits up to 50 ms, and tail latency rises for everyone sharing that process.

The canonical reference is the official Node.js learning material at https://nodejs.org/learn. The relevant guides are "The Node.js Event Loop", "Don't Block the Event Loop", "Understanding process.nextTick()" and "Understanding setImmediate()".

+--------------------------------------------------------------------+
|  JavaScript thread: runs application code, one callback at a time  |
|  handlers | JSON.parse | templates | promise continuations         |
|  shared by all in-flight requests; a blocked callback stalls all   |
+--------------------------------------------------------------------+
          ^                                               |
          | ready callbacks                               | async calls
          |                                               v
+--------------------------------------------------------------------+
|  libuv event loop: waits for ready work, hands callbacks back      |
+--------------------------------------------------------------------+
        |             ^                     |               ^
        | I/O         | ready               | work          | done
        v             |                     v               |
+------------------------------+    +--------------------------------+
| OS readiness mechanism       |    | libuv threadpool               |
| epoll | kqueue | IOCP        |    | 4 threads by default           |
| TCP sockets, HTTP servers    |    | (UV_THREADPOOL_SIZE)           |
| and clients                  |    | [T1] [T2] [T3] [T4]            |
| no thread per connection     |    | fs, dns.lookup, crypto, zlib   |
+------------------------------+    +--------------------------------+

Node.js runs all application code on one JavaScript thread, while libuv's event loop waits on OS readiness mechanisms for network I/O and sends fs, dns.lookup, crypto and zlib work to a small threadpool.

How it works: loop phases, microtasks and process.nextTick

Each turn of the loop visits the same phases in order:

  • Timers runs expired setTimeout and setInterval callbacks.
  • Pending callbacks runs a few system-level I/O callbacks that were deferred from the previous iteration.
  • Poll runs most I/O callbacks, such as socket reads and completed filesystem operations. If nothing else is scheduled, the loop blocks here waiting for I/O. The wait is capped by the nearest timer, so idle time shows up in poll.
  • Check runs setImmediate callbacks, immediately after poll.
  • Close callbacks runs handlers such as a socket's 'close' event.

The process.nextTick queue and promise microtasks are not phases. Node drains them completely after each callback, with nextTick first. The loop cannot move to its next callback until both queues are empty. A recursive nextTick, or a long chain of resolved promises, therefore keeps the loop away from poll indefinitely. Sockets go unread and timers fire late. Marking a function async does not yield to I/O. It only defers work into the microtask queue, which runs before any I/O.

API Where it runs Ordering guarantee
process.nextTick(fn) After the current callback, before promises Always before the loop continues
setImmediate(fn) Check phase Before timers when scheduled inside an I/O callback
setTimeout(fn, 0) Timers phase (delay clamped to 1 ms) Order versus setImmediate is non-deterministic from the main module

Treat these orderings as implementation details. Production code that depends on a callback landing in a specific phase will break under load. Tests that assert exact interleaving tend to become flaky. Await the actual event, or use explicit coordination.

const fs = require('node:fs');

// Part 1: ordering inside an I/O callback (runs in the poll phase).
fs.readFile(__filename, () => {
  setTimeout(() => console.log('4: setTimeout (timers, next iteration)'), 0);
  setImmediate(() => console.log('3: setImmediate (check, same iteration)'));
  Promise.resolve().then(() => console.log('2: promise microtask'));
  process.nextTick(() => console.log('1: nextTick'));
  // Both queues drain before the loop leaves this callback, so 1 and 2 print first.
  // Check comes right after poll, so 3 always prints before 4 here.
  // From the main module, the order of 3 and 4 would not be guaranteed.
});

// Part 2: a recursive nextTick starves the loop.
const start = Date.now();
setTimeout(() => {
  // Scheduled for 10 ms, but it cannot run until the nextTick queue is empty.
  console.log(`timer fired after ${Date.now() - start} ms (asked for 10)`);
}, 10);

let ticks = 0;
function spin() {
  // Each call queues another tick. The loop never reaches timers or poll
  // until this stops, so pending sockets and the readFile above wait too.
  if (++ticks < 5_000_000) process.nextTick(spin);
}
spin();

// Don't assert this exact interleaving in tests. It shows how scheduling works
// and isn't a contract. In real code, await the event you depend on.

Inside an I/O callback, nextTick and promise callbacks run first, then setImmediate, then setTimeout(fn, 0), and a recursive process.nextTick holds back a 10 ms timer and pending I/O until it stops.

Blocking the event loop: what it looks like and why p99 suffers

Anything that runs on the JavaScript thread without yielding holds up every other request in the process. The usual sources:

  • Synchronous core APIs: fs.readFileSync, crypto.pbkdf2Sync, zlib.gzipSync and the other *Sync variants.
  • JSON.parse / JSON.stringify on large payloads: the cost grows with payload size, and the call cannot be interrupted.
  • Catastrophic regex backtracking: a pattern such as nested quantifiers matched against crafted input can take exponential time.
  • Large in-memory sorts, and unbounded loops over request-sized input, where the client controls how long you spin.

Wrapping this work in async or a .then callback changes nothing. Promises decide when the code starts, not where it runs. A 30 ms loop inside an async function is still a 30 ms stall of the only JavaScript thread.

Why the tail moves first

Illustrative estimate, assumed inputs: each request needs 20 ms of synchronous CPU, and traffic arrives at 40 requests/s. The loop is busy 800 ms of every second. Arrivals are not evenly spaced, so some requests land behind others and wait. As load approaches 50 requests/s, utilisation approaches 100% and queueing delay grows sharply, not linearly.

The figure to track is per-request CPU cost multiplied by concurrent load. Neither number tells you much on its own. Averages hide the problem because most requests still arrive at an idle loop and finish quickly. The unlucky ones queue behind several 20 ms blocks, and they show up only in p99. A latency dashboard that tracks only the mean will look healthy until the service tips over.

// Shows that sync CPU inside an async handler still blocks the loop,
// and that p99 loop delay reveals it while the mean stays low.
const http = require('node:http');
const { monitorEventLoopDelay } = require('node:perf_hooks');

// Samples event loop delay; histogram values are in nanoseconds.
const h = monitorEventLoopDelay({ resolution: 10 });
h.enable();

// Stand-in for JSON.parse on a big body, a bad regex, a large sort, etc.
function burnCpu(ms) {
  const end = Date.now() + ms;
  while (Date.now() < end) {} // never yields to the loop
}

async function handle(req, res) {
  await Promise.resolve(); // being async does not move work off-thread
  burnCpu(20);             // 20 ms stall for every other request
  res.end('ok\n');
}

http.createServer((req, res) => {
  handle(req, res).catch(() => { res.statusCode = 500; res.end(); });
}).listen(3000);

// Report every 5 s, then reset so each window stands alone.
setInterval(() => {
  const ms = (ns) => (ns / 1e6).toFixed(1);
  console.log(
    `loop delay mean=${ms(h.mean)}ms ` +
    `p99=${ms(h.percentile(99))}ms max=${ms(h.max)}ms`
  );
  h.reset();
}, 5000).unref();

A server that burns 20 ms of synchronous CPU inside an async handler, with perf_hooks.monitorEventLoopDelay reporting mean and p99 loop delay. Under rising load, p99 climbs well ahead of the mean.

The libuv threadpool as a hidden bottleneck

Not all of the slow paths run on the JavaScript thread. libuv keeps a small worker threadpool for operations the operating system can't do asynchronously, and by default that pool has 4 threads. Everything that needs it shares those four: most fs calls, dns.lookup (which wraps the blocking getaddrinfo), crypto.pbkdf2, crypto.scrypt and the async zlib functions.

The pool is a fixed set of workers pulling from one queue. Suppose four password hashes are running. A fifth request then needs to read a template from disk and resolve a hostname for an outbound call. The fs.readFile and the dns.lookup both wait in that queue until a worker frees up. The loop isn't blocked, and event-loop delay can look normal. The request is still slow, and in traces the time shows up as a slow upstream connect or slow file access, so it looks like network latency. Async APIs only mean the JavaScript thread doesn't wait. Under load, those calls can still back up behind each other.

You can resize the pool with the UV_THREADPOOL_SIZE environment variable. Set it before the process starts, in the container spec or service unit. The pool is created the first time something uses it, and changing the variable after that has no effect. A bigger pool has costs:

  • Each thread needs its own stack memory.
  • More threads compete for the same CPU cores, so CPU-heavy hashing doesn't get faster once every core is busy.
  • It does nothing for blocking in your own JavaScript.

Size the pool to the work that actually waits in it, and measure before and after you change it.

// pool-demo.js
// Compare:  node pool-demo.js
//      vs:  UV_THREADPOOL_SIZE=8 node pool-demo.js
// The variable is set in the environment before startup, not in code.
const crypto = require('node:crypto');
const fs = require('node:fs');
const dns = require('node:dns');
const { performance } = require('node:perf_hooks');

const start = performance.now();
const ms = () => (performance.now() - start).toFixed(0);

// Occupy all 4 workers of the default pool with CPU-heavy hashing.
for (let i = 0; i < 4; i++) {
  crypto.pbkdf2('placeholder-password', 'placeholder-salt', 500_000, 64, 'sha512', () => {
    console.log(`pbkdf2 #${i} done at ${ms()} ms`);
  });
}

// Both of these also need a pool worker, so with 4 threads they wait
// in the queue behind the hashes instead of finishing almost immediately.
fs.readFile(__filename, () => console.log(`fs.readFile done at ${ms()} ms`));
dns.lookup('localhost', () => console.log(`dns.lookup done at ${ms()} ms`));

// Timers run on the event loop thread, not the pool. This fires on time,
// showing the loop is responsive even while the pool is saturated.
setTimeout(() => console.log(`setTimeout(10) fired at ${ms()} ms`), 10);

With the default pool of 4, fs.readFile and dns.lookup finish only after the pbkdf2 hashes free a worker, while the timer fires on schedule. Running with UV_THREADPOOL_SIZE=8 lets them complete right away.

How to measure loop delay and find the hot path

Loop delay: the symptom

perf_hooks.monitorEventLoopDelay() returns a histogram that samples how late a timer fires compared with when it was scheduled. Call enable(), then periodically read percentile(99) and max (values are in nanoseconds) and reset it. A rising p99 here usually precedes, and matches, a rising p99 in request latency, because every queued callback waits behind whatever held the thread.

performance.eventLoopUtilization() gives the busy ratio: the share of time the loop spent running callbacks rather than waiting in the poll phase. Pass a previous reading to get the delta for an interval. High utilization with low delay means the process is saturated but responsive. High delay with moderate utilization points to occasional long synchronous tasks.

CPU profiles: the cause

Both metrics tell you that the loop was blocked, not which code blocked it. To find that, you need a CPU profile. node --cpu-prof writes a .cpuprofile file on exit, and you can attach the inspector (--inspect) to record one on demand. Open either in Chrome DevTools and read it as a flame graph. Wide frames with no I/O beneath them are your synchronous hot path. Profiling adds overhead, so in production record short, targeted captures instead of leaving it on.

Export delay and utilization as metrics from day one. When tail latency spikes, you already know whether the loop is the culprit and can go straight to a profile. Without them, you spend that time ruling out the network, the database and GC first. The Diagnostics guides on nodejs.org/learn cover the profiling workflow in more depth.

Remediation options and alternatives, in order of cost

Once the profile points at a specific function, fix it with the cheapest option that works. Each step down this list adds operational cost, so escalate only when the previous step can't meet your latency target.

1. Make the work smaller or streaming. Parsing a 50 MB JSON body or building a large response in memory blocks the loop for the whole operation. With streams and backpressure, work is processed in chunks, so other requests get turns between them and memory stays bounded. This option is usually the cheapest because it removes work instead of moving it.

2. Partition across loop turns. Splitting a loop into batches and yielding with setImmediate lets pending I/O run between batches. Total CPU stays the same, and each job takes longer from start to finish. The job's state now spans multiple callbacks, so cancellation, errors and partial results become harder to reason about.

3. Offload to worker_threads. This moves CPU work off the main thread. Data passed to a worker is structured-cloned, each worker has its own V8 heap, and you need a pool to avoid paying startup cost per task. For small tasks, the serialization and messaging overhead can be larger than the work you offloaded.

4. Scale out with cluster or more instances. This gives you real parallelism, and a crash affects only one process. Memory isn't shared between processes. In-process caches, rate limiters and sessions either move to an external store or must tolerate each process seeing different state.

Option Reduces total CPU Main cost Use when
Streams + backpressure Often Refactoring to chunked APIs Large payloads, files, bulk I/O
setImmediate partitioning No Slower jobs, scattered state Moderate CPU, simple loops
worker_threads No (moves it) Cloning, per-worker memory, pooling Heavy, coarse-grained CPU tasks
cluster / instances No (spreads it) External state stores Throughput limits, fault isolation

When to avoid Node.js for the work, and the impact on cost and engineering time

Workers move CPU work off the loop, but they do not change what Node.js is good at. A pool of worker_threads running image transcoding, large compression jobs or numeric work all day still pays V8 heap overhead per worker, structured-clone costs on every message, and garbage-collection pauses. Those are costs a native runtime does not carry. When CPU-bound work is sustained rather than occasional, the better answer is usually a separate service in a compiled language, or a Node-API addon for a narrow, hot function. Both have real costs. A separate service adds deployment, versioning, network failure modes and tracing across a boundary. An addon adds a native build toolchain, per-platform binaries and crashes that take down the whole process.

Work profile Reasonable home Main cost you take on
Sub-millisecond sync work, I/O-heavy Event loop Ordering discipline
Bursty heavy CPU tasks worker_threads pool Serialization, memory
Sustained CPU, hot inner function Node-API addon Native builds, crash risk
Sustained CPU, independent workload Separate service Ops, network boundary

Illustrative estimate (assumed numbers, not a benchmark). Loop utilization ≈ requests/sec × sync ms per request ÷ 1000. With an assumed 120 req/s and 6 ms of sync work, that is 120 × 6 ÷ 1000 = 0.72. At that level, queueing delay grows quickly and p99 latency rises.

If you price every synchronous millisecond this way at design time, the choice between these options becomes an arithmetic question. That stops teams from building premature architecture. It also prevents fragile code that depends on nextTick ordering, and it avoids repeated tail-latency incidents that each take engineering time to investigate.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to nodejs event loop, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.