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:
| API or flag | Stability (Node.js 26 docs) | Version notes |
|---|---|---|
|
2 Stable |
|
|
Stable |
Added in v25.2.0 and v24.12.0; on Node.js 22 use |
|
Stable |
Added in v26.5.0 and v24.19.0; not available on Node.js 22. |
|
Stable |
Added in v26.10.0; not available on 24 or 22. |
|
2 Stable |
Added in v15.0.0. |
|
1 Experimental |
Added in v17.3.0 and v16.14.0; still experimental on Node.js 26. |
|
2 Stable |
Covered in depth on Worker Threads. |
|
2 Stable |
The asynchronous variants run on libuv’s thread pool. |
|
Documented command-line option and environment variable |
|
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:
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:
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.
| 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, |
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 |
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:
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`);
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.
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.
// 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'));
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.
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); }
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.
| Module | Avoid in a server | Use instead |
|---|---|---|
|
|
The callback or promise forms ( |
|
|
Streams or the asynchronous callbacks. |
|
|
|
|
|
|
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:
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();
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.
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());
});
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.
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
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.
| 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.
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);
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.
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));
}
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.
| API | What it reports | Reading it |
|---|---|---|
|
A histogram ( |
Look at a high percentile or the maximum, not the mean. With the default timer-based sampling, an idle loop reads close to the |
|
An object with cumulative |
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.
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();
idle: {"eventLoopUtilization":0.025,"delayP99Ms":21,"delayMaxMs":21}
after two blocking requests: {"eventLoopUtilization":1,"delayP99Ms":300.9,"delayMaxMs":300.9}
Source: perf_hooks.monitorEventLoopDelay(), perf_hooks.monitorEventLoopDelay() in the v24 documentation (samplePerIteration added in v24.19.0)
A second, smaller example reads the same two measures around a workload in which most callbacks are fast and one is slow:
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));
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
| Version | Change |
|---|---|
v25.2.0 and v24.12.0 |
|
v24.19.0 and v26.5.0 |
|
v26.9.0 and v26.10.0 |
|
v24.7.0 |
|
v22.7.0 and v20.18.0 |
|
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.
| 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 |
Efficient Node.js, Samer Buna (O’Reilly, 2025) |
It is silent on |