Don’t Block the Event Loop

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Node.js 26 (26.10.0, released 2026-09-21, the Current line that becomes LTS on 2026-10-28), with the differences of Node.js 24 "Krypton" (24.21.0, Active LTS) and Node.js 22 "Jod" (22.23.3, Maintenance) called out. All versions and stability levels were checked against the official documentation on 2026-10-03, and every example below was run on all three lines unless the text says otherwise. Timings are from one laptop and are illustrative; run the examples yourself to see your own numbers. A Node.js server answers many clients with one thread that runs JavaScript, so a single slow callback delays every other client until it returns. This page explains what blocking means precisely, lists the usual culprits (CPU-heavy loops, regular expression backtracking, very large JSON documents and synchronous APIs), compares the two cures (partitioning the work and offloading it to other threads), and shows how to measure the damage in production. The mechanics of the loop itself are on The Event Loop; the architecture around it is on Introduction & Architecture.

The stability level of what this page covers is not uniform:

Table 1. APIs and flags used on this page
API or flag Stability (Node.js 26 docs) Version notes

node:perf_hooks module

2 Stable

monitorEventLoopDelay() added in v11.10.0; performance.eventLoopUtilization() added in v14.10.0 and v12.19.0.

perf_hooks.eventLoopUtilization() (alias on the module itself)

Stable

Added in v25.2.0 and v24.12.0; on Node.js 22 use performance.eventLoopUtilization().

samplePerIteration option of monitorEventLoopDelay()

Stable

Added in v26.5.0 and v24.19.0; not available on Node.js 22.

createSlidingWindowHistogram()

Stable

Added in v26.10.0; not available on 24 or 22.

node:timers/promises setImmediate() and setTimeout()

2 Stable

Added in v15.0.0.

scheduler.yield() and scheduler.wait() (node:timers/promises)

1 Experimental

Added in v17.3.0 and v16.14.0; still experimental on Node.js 26.

node:worker_threads

2 Stable

Covered in depth on Worker Threads.

node:fs, node:crypto, node:zlib synchronous and asynchronous variants

2 Stable

The asynchronous variants run on libuv’s thread pool.

--trace-sync-io, UV_THREADPOOL_SIZE

Documented command-line option and environment variable

--trace-sync-io added in v2.1.0; the default pool size is 4.

Blocking versus non-blocking

The Node.js Learn article defines blocking as the situation where additional JavaScript in the process must wait until a non-JavaScript operation completes, because the event loop cannot run JavaScript while that operation is in progress. By that definition the usual blocking operations are the synchronous methods of the standard library whose names end in Sync, plus the synchronous methods of native add-ons. The same article notes that JavaScript that is merely CPU-intensive is not usually called blocking. The other Learn article, "Don’t Block the Event Loop", uses a wider practical definition: any callback, await continuation or Promise.then handler that takes long enough to keep other clients waiting is a problem, whatever the reason. This page uses the wider definition, and says "synchronous I/O" when it means the narrow one.

Every I/O method in the standard library has an asynchronous form. The difference is visible in the order of events:

A synchronous read blocks, an asynchronous read does not (order.mjs)
import { readFileSync } from 'node:fs';
import { readFile } from 'node:fs/promises';

// Blocking: the next line does not run until the file has been read.
const text = readFileSync(import.meta.filename, 'utf8');
console.log('1 sync read finished,', text.length, 'characters');

// Non-blocking: the read is handed to the thread pool and JavaScript keeps going.
const pending = readFile(import.meta.filename, 'utf8');
console.log('2 async read started');
console.log('3 this line runs before the read completes');
console.log('4 async read finished,', (await pending).length, 'characters');

Run on Node.js 26, 24 and 22, the output is the same four lines in this order:

Output
1 sync read finished, 591 characters
2 async read started
3 this line runs before the read completes
4 async read finished, 591 characters

The Learn article also warns about mixing the two styles: if an asynchronous fs.readFile() of a file is started and a synchronous fs.unlinkSync() of the same file follows, the delete is likely to run before the read has happened. The remedy is to keep the whole sequence asynchronous and ordered (delete inside the read’s callback, or await the read and then await the delete), or to keep it entirely synchronous in a place where blocking is acceptable, such as a start-up script. For how the three flavours of the file system API compare, see File System.

Table 2. What runs where
Thread Runs Examples

The event loop (main thread)

All of your JavaScript: start-up code and every callback; it also fulfils non-blocking requests such as network I/O through the operating system.

Request handlers, await continuations, JSON.parse(), regular expression matching, *Sync methods.

libuv’s thread pool (4 threads by default)

Work for which the operating system has no non-blocking form, and some CPU-intensive tasks.

Asynchronous node:fs calls, dns.lookup(), dns.lookupService(), asynchronous crypto.pbkdf2(), crypto.scrypt(), crypto.randomBytes(), crypto.randomFill() and crypto.generateKeyPair(), asynchronous node:zlib calls.

Your own worker threads or child processes

Anything you choose to offload; each has its own event loop (workers) or its own process.

Image resizing, PDF generation, parsing a huge document.

Network sockets are not on the pool: the loop asks the operating system (epoll, kqueue, IOCP) to watch them and is told when one is ready, which is why a Node.js server can hold thousands of idle connections with one thread.

What a blocked loop looks like

Any synchronous code that runs for a long time starves everything else, including timers that were due. The next example starts a timer that should tick every 100 ms, blocks the loop for 600 ms, and uses monitorEventLoopDelay() (introduced below) to record how late the loop was:

Ticks stop while a synchronous loop runs (blocked-timer.mjs)
import { monitorEventLoopDelay, performance } from 'node:perf_hooks';
import { setTimeout as sleep } from 'node:timers/promises';

const delay = monitorEventLoopDelay({ resolution: 10 });
delay.enable();

const started = performance.now();
const tick = setInterval(() => {
  console.log(`tick at ${Math.round(performance.now() - started)} ms`);
}, 100);

// A synchronous busy loop: nothing else can run until it returns.
function spin(ms) {
  const end = performance.now() + ms;
  while (performance.now() < end) { /* burn CPU */ }
}

await sleep(250);
console.log('starting a 600 ms synchronous loop');
spin(600);
console.log('loop finished');
await sleep(250);

clearInterval(tick);
delay.disable();
console.log(`max loop delay: ${(delay.max / 1e6).toFixed(0)} ms`);
console.log(`p99 loop delay: ${(delay.percentile(99) / 1e6).toFixed(0)} ms`);
Output (Node.js 26; 24 and 22 differ by a few milliseconds)
tick at 101 ms
tick at 201 ms
starting a 600 ms synchronous loop
loop finished
tick at 851 ms
tick at 951 ms
tick at 1051 ms
max loop delay: 607 ms
p99 loop delay: 607 ms

No tick appears between 201 ms and 851 ms, and no other client would have been served either. The figure below shows the same effect for two clients: Client A asks for something that costs 300 ms of CPU, Client B asks for something that costs 1 ms.

sequenceDiagram participant A as Client A (300 ms of CPU) participant B as Client B (1 ms of work) participant L as Event loop participant W as Worker thread rect rgb(255, 235, 235) Note over A,W: The work runs on the event loop A->>L: GET /report B->>L: GET /ping, arrives 10 ms later activate L Note over L: runs the report callback for 300 ms, nothing else can run L-->>A: response after 300 ms deactivate L L-->>B: response after about 301 ms instead of 1 ms end rect rgb(235, 245, 235) Note over A,W: The work is offloaded or partitioned A->>L: GET /report L->>W: hand over the 300 ms job B->>L: GET /ping, arrives 10 ms later L-->>B: response after about 1 ms W-->>L: result of the job L-->>A: response after about 300 ms end
Figure 1. Latency of a small request with and without a blocked loop

CPU-heavy work and computational complexity

A callback that takes the same number of steps whatever its input is safe: every pending client gets its turn. A callback whose cost depends on its input is a risk, because the cost is controlled by whoever sends the input. The practical habit recommended by the Learn article is to reason about the complexity of each handler, then bound the input so that the worst case is known: reject overlong strings, cap array lengths, limit page sizes, and evaluate the cost of the longest input you still accept. A loop over user-controlled n that does \(O(n)\) work is bad for large n; two nested loops over the same n do \(O(n^2)\) work and are far worse. Regular expressions and JSON are the two exceptions the Learn article calls out where V8 is not as fast as the rest of the language, and they get their own sections below.

Note that "single-threaded" is a simplification: Node.js itself uses more threads (the pool, V8’s garbage-collection helpers), but only one of them runs your JavaScript. See The Event Loop for the complete picture.

Regular expression denial of service (ReDoS)

A regular expression is normally matched in a single pass over the input. Some patterns, on some inputs, make a backtracking engine try an exponential number of paths, and the exponential case is triggered by input that almost matches and then fails. V8’s engine is a backtracking engine, so a vulnerable pattern applied to attacker-controlled text is a denial-of-service hole: the whole process stops serving other clients while one match runs. The rules of thumb from the Learn article are to avoid nested quantifiers such as (a+), to avoid alternations with overlapping branches such as (a|a), to avoid backreferences, and to use indexOf() or startsWith() for simple string matches. Libraries such as safe-regex or an RE2-based engine can help, but none of them catches every vulnerable pattern, and RE2 is not fully compatible with V8’s syntax, so test for regressions.

A vulnerable path pattern compared with a safe one, with input bounded (redos.mjs)
// Vulnerable: a quantified group that contains another quantifier.
const vulnerable = /^(\/.+)+$/;
// Safer: one path segment is one or more non-slash characters; no overlap.
const safe = /^(?:\/[^/\n]+)+$/;

function time(re, input) {
  const t = performance.now();
  const ok = re.test(input);
  return { ok, ms: Math.round(performance.now() - t) };
}

time(vulnerable, "/".repeat(25) + "\n"); // warm up: V8 compiles the pattern on first use
for (const n of [34, 36, 38, 40]) {
  // n slashes followed by a newline, which '.' does not match: the match must fail.
  const evil = '/'.repeat(n) + '\n';
  console.log(n, 'vulnerable', time(vulnerable, evil), 'safe', time(safe, evil));
}

// Bounding the input is the cheapest defence of all.
const MAX_PATH = 1024;
export function isValidPath(input) {
  return typeof input === 'string' && input.length <= MAX_PATH && safe.test(input);
}
console.log(isValidPath('/books/42'), isValidPath('/'.repeat(100_000) + '\n'));
Output (Node.js 26; 24 and 22 are within a few percent)
34 vulnerable { ok: false, ms: 33 } safe { ok: false, ms: 0 }
36 vulnerable { ok: false, ms: 88 } safe { ok: false, ms: 0 }
38 vulnerable { ok: false, ms: 226 } safe { ok: false, ms: 0 }
40 vulnerable { ok: false, ms: 593 } safe { ok: false, ms: 0 }
true false

Each two extra characters multiply the time by about 2.6, so a 100-character attack string would never finish. The safe pattern rejects the same input instantly because its alternatives cannot overlap, and the length check in isValidPath() means the matcher never even sees an oversized string.

The cost of JSON

JSON.parse() and JSON.stringify() are linear in the size of their input, but for large documents the constant is high, and both run on the event loop. A server that accepts JSON from clients must therefore limit the body size before parsing, and a server that returns very large results should stream them or paginate them instead of building one huge string. On the Learn article’s own numbers, a 50 MB document took about 0.7 s to stringify and 1.3 s to parse; the example below reproduces the effect with an object that doubles at every nesting level.

Measuring JSON cost and refusing oversized payloads (json-cost.mjs)
function build(depth) {
  let obj = { a: 1 };
  for (let i = 0; i < depth; i++) obj = { left: obj, right: obj };
  return obj;
}
const ms = (t) => Math.round(performance.now() - t);

const obj = build(21);
let t = performance.now();
const text = JSON.stringify(obj);
console.log(`stringify: ${ms(t)} ms, ${(text.length / 1e6).toFixed(1)} MB`);
t = performance.now();
JSON.parse(text);
console.log(`parse:     ${ms(t)} ms`);

// Defence: refuse oversized bodies before parsing.
const MAX_BYTES = 100_000;
export function parseBody(body) {
  if (Buffer.byteLength(body) > MAX_BYTES) throw new RangeError('payload too large');
  return JSON.parse(body);
}
try { parseBody(text); } catch (err) { console.log(err.name + ': ' + err.message); }
Output (Node.js 26; stringify took about twice as long on 24 and 22 in my runs)
stringify: 63 ms, 52.4 MB
parse:     243 ms
RangeError: payload too large

A 52 MB parse keeps the loop busy for roughly a quarter of a second on this machine, long enough to delay every other client by that much. HTTP frameworks usually have a body limit setting for exactly this reason; the HTTP server pages cover the knobs, see HTTP Server Fundamentals. For documents that must be large, process them as a stream (see Streams), use a streaming JSON parser from npm, or parse in a worker.

Synchronous APIs in servers

Several core modules have synchronous variants that exist for scripts and start-up code. The Learn article lists the ones to keep off the event loop in a server, and the same reasoning applies to the rest of the *Sync family.

Table 3. Synchronous APIs to avoid inside request handlers, and their replacements
Module Avoid in a server Use instead

node:crypto

crypto.pbkdf2Sync(), crypto.randomFillSync(), the synchronous form of crypto.randomBytes(), crypto.scryptSync(); very large inputs to encryption routines.

The callback or promise forms (util.promisify() makes a promise of crypto.pbkdf2()), which run on the thread pool.

node:zlib

zlib.inflateSync(), zlib.deflateSync() and the other *Sync calls.

Streams or the asynchronous callbacks.

node:fs

fs.readFileSync(), fs.writeFileSync(), fs.statSync() and every other *Sync call; latency is unpredictable on network file systems.

node:fs/promises, or streams for large files.

node:child_process

spawnSync(), execSync(), execFileSync().

spawn() and execFile() with an argument array and no shell, see Child Processes.

Synchronous calls are fine before the server starts listening: reading a configuration file or a TLS certificate at boot is a common and harmless use. The next example shows what a synchronous password-hashing call costs in a running server. Four sign-ups arrive together, a health check follows 20 ms later, and the program prints how long the health check waited:

Synchronous versus asynchronous hashing in a request handler (server-sync-vs-async.mjs)
import { createServer } from 'node:http';
import { once } from 'node:events';
import { pbkdf2, pbkdf2Sync, randomBytes } from 'node:crypto';
import { promisify } from 'node:util';

const pbkdf2Async = promisify(pbkdf2);
const salt = randomBytes(16);
const mode = process.argv[2] ?? 'async';

const server = createServer(async (req, res) => {
  if (req.url === '/ping') {
    res.end('pong\n');
    return;
  }
  // Stand-in for hashing a password during sign-up.
  const key = mode === 'sync'
    ? pbkdf2Sync('correct horse', salt, 600_000, 32, 'sha256')
    : await pbkdf2Async('correct horse', salt, 600_000, 32, 'sha256');
  res.end(key.toString('hex').slice(0, 8) + '\n');
});
server.listen(0, '127.0.0.1');
await once(server, 'listening');
const base = `http://127.0.0.1:${server.address().port}`;

// Four sign-ups arrive together; a /ping arrives 20 ms later.
const hashes = Array.from({ length: 4 }, () => fetch(`${base}/hash`).then((r) => r.text()));
await new Promise((r) => setTimeout(r, 20));
const t0 = performance.now();
await fetch(`${base}/ping`).then((r) => r.text());
console.log(`${mode}: /ping answered after ${Math.round(performance.now() - t0)} ms`);

await Promise.all(hashes);
server.close();
Output of node server-sync-vs-async.mjs sync and node server-sync-vs-async.mjs async (Node.js 26)
sync: /ping answered after 128 ms
async: /ping answered after 7 ms

On Node.js 24 the results were 131 ms and 13 ms, and on Node.js 22 they were 132 ms and 7 ms. The iteration count used here is only for demonstration; choose parameters for real password hashing from the guidance on Crypto, Passwords & Secrets.

Finding synchronous calls with --trace-sync-io

The command-line flag --trace-sync-io prints a warning with a stack trace whenever synchronous I/O is detected after the first turn of the event loop. It is a diagnostic aid for development, not something to leave on in production, and it is not exhaustive: in my tests on 26, 24 and 22, fs.readFileSync() was reported when called without an encoding but not when called with one such as 'utf8', so a clean run does not prove there is no synchronous I/O.

A request handler that reads a file synchronously (syncio.mjs)
import { readFileSync } from 'node:fs';
import { createServer } from 'node:http';

const server = createServer((req, res) => {
  // Synchronous I/O inside a request handler: this is what --trace-sync-io reports.
  const page = readFileSync(import.meta.filename);
  res.end(`${page.length}\n`);
  server.close();
});

server.listen(0, '127.0.0.1', async () => {
  const { port } = server.address();
  await fetch(`http://127.0.0.1:${port}/`).then((r) => r.text());
});
Run with the flag; the output is trimmed to the lines that matter (Node.js 26)
node --trace-sync-io syncio.mjs
(node:63850) WARNING: Detected use of sync API
    at openSync (node:fs:778:18)
    at readFileSync (node:fs:624:35)
    at file:///.../syncio.mjs:6:16
    at emit (node:events:514:20)
    at parserOnIncoming (node:_http_server:1394:12)

The example is written with callbacks on purpose. When the same code runs inside a top-level await chain, the first turn of the event loop has not finished, and in my run the flag printed nothing.

Do not block the thread pool either

The thread pool has a fixed size, four threads by default, and the Learn article’s advice is symmetrical: keep each task short and keep tasks similar in length, because one slow task effectively removes one worker until it finishes. If four slow asynchronous crypto or zlib calls occupy the pool, an unrelated fs.readFile() or dns.lookup() queues behind them even though the event loop is idle. The size can be changed with the UV_THREADPOOL_SIZE environment variable, which has to be set before the process starts: the documentation notes that setting process.env.UV_THREADPOOL_SIZE from inside the program is not guaranteed to work, because the pool already exists by then.

Eight hashes queued on the pool with different pool sizes (pool.mjs)
import { pbkdf2 } from 'node:crypto';
import { promisify } from 'node:util';

const hash = promisify(pbkdf2);
const t = performance.now();
// Eight independent hashes, all submitted to libuv's thread pool at once.
await Promise.all(Array.from({ length: 8 }, (_, i) =>
  hash(`password-${i}`, 'salt-for-demo', 300_000, 32, 'sha256')));
console.log(`UV_THREADPOOL_SIZE=${process.env.UV_THREADPOOL_SIZE ?? '(default)'}: ` +
  `${Math.round(performance.now() - t)} ms`);
node pool.mjs
UV_THREADPOOL_SIZE=8 node pool.mjs
UV_THREADPOOL_SIZE=1 node pool.mjs
Output (Node.js 26)
UV_THREADPOOL_SIZE=(default): 60 ms
UV_THREADPOOL_SIZE=8: 53 ms
UV_THREADPOOL_SIZE=1: 168 ms

With a single thread the eight hashes run one after another and take about three times as long. A larger pool does not help CPU-bound tasks beyond the number of cores, because each task only progresses while it is scheduled on a core. To reduce variation in task length the Learn article recommends partitioning long tasks: for example, read a large file with a stream, which is partitioned automatically, instead of one big read, and never let a user-supplied path reach a slow file such as a device.

Partitioning versus offloading

When a computation really is expensive there are two ways to keep the loop responsive. Partitioning splits the work into slices that each run quickly on the event loop and yields between slices. Offloading moves the work to another thread or process so that the event loop only orchestrates.

Table 4. Choosing between partitioning and offloading
Aspect Partitioning Offloading

Where the work runs

On the event loop, in slices.

In a worker thread, a child process, or a native add-on using the thread pool.

Uses more than one core

No.

Yes.

Overhead

Small, one scheduling step per slice, and the total time grows slightly.

Starting a worker or process, plus copying the data in and out (structured clone or serialization); only the event loop can see the application’s JavaScript state.

Best for

Simple, easily sliced loops over arrays or iterations.

Complex or long computations, such as image processing, compression of big buffers or parsing very large documents.

Typical pitfall

Slices that are still too long; shared state that changes between slices.

Starting one worker or process per request, which an attacker can turn into a fork bomb; use a bounded pool.

Partitioning on the event loop

The Learn article partitions a loop by scheduling the next step with setImmediate(). The modern equivalent is await on the promise form of setImmediate() from node:timers/promises, which is Stable. scheduler.yield() from the same module does the same job but is still marked Experimental on Node.js 26, so this page uses the stable form.

Slicing a long loop with setImmediate, compared with the blocking version (partition.mjs)
import { setImmediate as yieldToLoop, setTimeout as sleepFor } from 'node:timers/promises';
import { monitorEventLoopDelay } from 'node:perf_hooks';

const items = Array.from({ length: 10_000_000 }, (_, i) => i);
const work = (n) => Math.sqrt(n) * Math.sin(n);

function sumBlocking(list) {
  let total = 0;
  for (const n of list) total += work(n);
  return total;
}

async function sumPartitioned(list, chunk = 50_000) {
  let total = 0;
  for (let i = 0; i < list.length; i += chunk) {
    for (let j = i; j < Math.min(i + chunk, list.length); j++) total += work(list[j]);
    await yieldToLoop(); // setImmediate: timers and I/O get a turn
  }
  return total;
}

async function measure(label, fn) {
  const h = monitorEventLoopDelay({ resolution: 5 });
  h.enable();
  await sleepFor(50); // let the sampling timer start
  const t = performance.now();
  const total = await fn(items);
  await sleepFor(30); // let the delay timer fire once more
  h.disable();
  console.log(`${label}: ${Math.round(performance.now() - t)} ms total, ` +
    `worst loop delay ${(h.max / 1e6).toFixed(0)} ms, result ${total.toFixed(2)}`);
}

await measure('blocking   ', sumBlocking);
await measure('partitioned', sumPartitioned);
Output (Node.js 26; 24 and 22 differ by a few milliseconds)
blocking   : 471 ms total, worst loop delay 442 ms, result 1961.53
partitioned: 414 ms total, worst loop delay 8 ms, result 1961.53

The total time is about the same, but the worst delay seen by other work drops from 442 ms to 8 ms because timers and I/O get a turn between slices. Tune the chunk size: smaller slices improve fairness and increase overhead. The ordering of setImmediate() relative to timers and microtasks is explained in The Event Loop. Awaiting an already resolved promise is not enough to yield, because promise continuations run in the microtask queue before the loop moves on.

Offloading to a worker thread

A worker thread has its own V8 isolate and its own event loop, so a CPU-heavy loop in it does not delay the main thread. The following single-file example starts a worker from the same module, receives the result through a message, and measures the main thread’s loop delay while the worker computes.

Moving the loop into a worker thread (offload.mjs)
import { Worker, isMainThread, parentPort, workerData } from 'node:worker_threads';
import { monitorEventLoopDelay } from 'node:perf_hooks';
import { setTimeout as sleep } from 'node:timers/promises';

function sumOfRoots(count) {
  let total = 0;
  for (let i = 0; i < count; i++) total += Math.sqrt(i) * Math.sin(i);
  return total;
}

if (isMainThread) {
  const delay = monitorEventLoopDelay({ resolution: 5 });
  delay.enable();
  await sleep(50); // let the sampling timer start

  // The heavy loop runs in a second thread with its own event loop and V8 isolate.
  const worker = new Worker(new URL(import.meta.url), { workerData: { count: 10_000_000 } });
  const [result] = await Promise.all([
    new Promise((resolve, reject) => {
      worker.once('message', resolve);
      worker.once('error', reject);
    }),
    sleep(100), // the main thread stays free to run timers meanwhile
  ]);
  await sleep(30);
  delay.disable();
  console.log(`result ${result.toFixed(2)}, worst main-thread loop delay ${(delay.max / 1e6).toFixed(0)} ms`);
} else {
  parentPort.postMessage(sumOfRoots(workerData.count));
}
Output (Node.js 26, 24 and 22; the main-thread delay was 6 to 8 ms on all three)
result 1961.53, worst main-thread loop delay 8 ms

Source: Worker threads

Creating a worker per request is wasteful and unbounded, so real servers keep a small pool of long-lived workers, sized near the number of available cores, and queue jobs for them; that design, postMessage(), transferables and SharedArrayBuffer are covered on Worker Threads. If isolation matters more than cost, or the job is another program, use a child process (Child Processes); to run one server process per core, see Cluster & Scaling. The Learn article’s remark still holds: if your server depends heavily on complex calculations, consider whether Node.js is the right tool for that part of the system.

Measuring: event loop delay and utilization

You cannot fix what you do not measure, and event loop health is a better saturation signal than CPU percentage, because a loop can be saturated while the process uses one core of many. node:perf_hooks provides two complementary tools.

Table 5. Tools for measuring event loop health
API What it reports Reading it

monitorEventLoopDelay([options])

A histogram (min, max, mean, stddev, percentile(p), percentiles, reset()) of event loop delay in nanoseconds. enable() and disable() start and stop it.

Look at a high percentile or the maximum, not the mean. With the default timer-based sampling, an idle loop reads close to the resolution you chose, so subtract that baseline.

performance.eventLoopUtilization([utilization1[, utilization2]])

An object with cumulative idle and active times and a utilization ratio between 0 and 1. Passing a previous result returns the change since then, like process.hrtime(previous).

Near 0 means the loop is mostly waiting; near 1 means it never rests. A busy loop that serves requests quickly can show a high value without anyone being hurt, so read it together with the delay histogram.

The samplePerIteration option (added in v26.5.0 and v24.19.0) changes the sampling method to once per loop iteration, so the histogram does not keep an idle loop alive or force extra iterations; the documentation states that the two modes give significantly different results and must not be compared. On Node.js 22 the option is ignored and the timer mode is always used. The next example exposes both measures from a /health route and then fires two deliberately blocking requests.

Exposing event loop delay and utilization on a health endpoint (health.mjs)
import { createServer } from 'node:http';
import { once } from 'node:events';
import { monitorEventLoopDelay, performance } from 'node:perf_hooks';

const delay = monitorEventLoopDelay({ resolution: 20 });
delay.enable();
let lastElu = performance.eventLoopUtilization();

const server = createServer((req, res) => {
  if (req.url === '/health') {
    // Delta since the previous call, like process.hrtime(previous).
    const elu = performance.eventLoopUtilization(lastElu);
    lastElu = performance.eventLoopUtilization();
    const body = {
      eventLoopUtilization: Number(elu.utilization.toFixed(3)),
      delayP99Ms: Number((delay.percentile(99) / 1e6).toFixed(1)),
      delayMaxMs: Number((delay.max / 1e6).toFixed(1)),
    };
    delay.reset(); // start a fresh window for the next scrape
    res.setHeader('content-type', 'application/json');
    res.end(JSON.stringify(body));
    return;
  }
  if (req.url === '/slow') {
    const end = performance.now() + 300; // deliberately blocking
    while (performance.now() < end) { /* burn CPU */ }
  }
  res.end('ok\n');
});
server.listen(0, '127.0.0.1');
await once(server, 'listening');
const base = `http://127.0.0.1:${server.address().port}`;
const get = (path) => fetch(base + path).then((r) => r.text());

await new Promise((r) => setTimeout(r, 200));
console.log('idle: ', await get('/health'));
await get('/slow');
await get('/slow');
console.log('after two blocking requests: ', await get('/health'));
server.close();
delay.disable();
Output (Node.js 26; 24 and 22 are within a few milliseconds)
idle:  {"eventLoopUtilization":0.025,"delayP99Ms":21,"delayMaxMs":21}
after two blocking requests:  {"eventLoopUtilization":1,"delayP99Ms":300.9,"delayMaxMs":300.9}

A second, smaller example reads the same two measures around a workload in which most callbacks are fast and one is slow:

Percentiles show the slow callback that a mean would hide (measure.mjs)
import { monitorEventLoopDelay, performance } from 'node:perf_hooks';
import { setTimeout as sleep } from 'node:timers/promises';

const delay = monitorEventLoopDelay({ resolution: 10 });
delay.enable();
const before = performance.eventLoopUtilization();

function spin(ms) {
  const end = performance.now() + ms;
  while (performance.now() < end) { /* burn CPU */ }
}

for (let i = 0; i < 5; i++) {
  await sleep(100);
  spin(i === 3 ? 400 : 20); // one slow callback among fast ones
}

const elu = performance.eventLoopUtilization(before);
delay.disable();
console.log('ELU since start:', elu.utilization.toFixed(2));
console.log('delay p50/p99/max (ms):',
  (delay.percentile(50) / 1e6).toFixed(1),
  (delay.percentile(99) / 1e6).toFixed(1),
  (delay.max / 1e6).toFixed(1));
Output (Node.js 26)
ELU since start: 0.49
delay p50/p99/max (ms): 11.0 402.4 402.4

The median is within a millisecond of the timer resolution while the 99th percentile exposes the 400 ms stall. In production, export these numbers to your metrics system, alert on the delay percentile and use utilization for capacity planning and load shedding. Newer versions add createSlidingWindowHistogram() (v26.10.0) for windowed percentiles. When the numbers are bad, find the culprit with the profilers described on Profiling & Performance; the deployment side is on Production Deployment.

What changed recently

Table 6. Changes since older tutorials that matter for this page
Version Change

v25.2.0 and v24.12.0

perf_hooks.eventLoopUtilization() and perf_hooks.timerify() became available directly on the module, in addition to the performance object. Code that must also run on Node.js 22 should keep using performance.eventLoopUtilization().

v24.19.0 and v26.5.0

monitorEventLoopDelay() gained the samplePerIteration option.

v26.9.0 and v26.10.0

perf_hooks.importHistogram() (26.9) and createSlidingWindowHistogram() (26.10) were added.

v24.7.0

crypto.argon2() and crypto.argon2Sync() were added; the asynchronous form is the one for servers.

v22.7.0 and v20.18.0

process.nextTick() became Legacy; a recursive nextTick or promise chain starves the loop just like a synchronous loop, so prefer setImmediate() to yield.

The Learn article itself is dated: its list of synchronous APIs says it is reasonably complete as of Node.js v9, its worker suggestions mention C++ add-ons built with NAN and the node-webworker-threads package, and it describes fs.readFile() as it behaved before v10. Since then node:worker_threads has become stable and is the first thing to reach for, and the thread pool guidance still holds.

Where the books are outdated

Both reference books touch this concept only briefly, and a few statements need correcting for current Node.js.

Table 7. Corrections to the books on this topic
Book Where it is outdated, narrower or silent

Node.js Projects, Jonathan Wexler (O’Reilly, 2025)

It uses "worker threads" for the threads of libuv’s pool and lists database queries as pool work; in the official documentation the pool is libuv’s, network I/O (including database drivers over sockets) does not use it, and node:worker_threads is a separate, user-controlled facility. It also contrasts constant time with "exponential" time while giving nested loops as the example; nested loops are polynomial, while exponential cost is what backtracking regular expressions produce. Its password hashing example calls a synchronous crypto function in a handler; use the asynchronous form shown above.

Efficient Node.js, Samer Buna (O’Reilly, 2025)

It is silent on worker_threads and on measuring the loop with monitorEventLoopDelay() and eventLoopUtilization(), which this page and Worker Threads cover.