The default libuv threadpool is 4. That number is folklore-adjacent for modern multi-core hosts running fs, crypto.pbkdf2, and dns.lookup under load. Blindly setting UV_THREADPOOL_SIZE=64 can also thrash. The unfair advantage is a benchmark harness that measures event-loop lag and throughput while you sweep pool sizes — then you pin the winner in the task definition with evidence attached to the PR.
⚡ TL;DR: Identify whether your bottleneck uses the UV pool (
fs,cryptoasync,dns.lookup); sweep 4→8→16→24 under production-like concurrency; watch event-loop delay + CPU steal; setUV_THREADPOOL_SIZEin the process environment before Node starts. Pair with Event Loop Blockers and N-API Addons.
Know what hits the pool
// Things that queue on UV threadpool (async forms):
// fs.readFile / fs.promises.* (non-sync)
// crypto.pbkdf2, scrypt, randomBytes (async)
// dns.lookup (not dns.resolve*)
// some zlib async APIs
// ❌ DNS resolve via c-ares may NOT use the pool the same way — measure!
// src/bench-pool.ts
import { pbkdf2 } from "node:crypto";
import { monitorEventLoopDelay } from "node:perf_hooks";
import { Worker } from "node:worker_threads"; // not needed — just concurrent pbkdf2
const h = monitorEventLoopDelay({ resolution: 20 });
h.enable();
const N = 200;
const concurrency = 32;
let done = 0;
const t0 = Date.now();
function kick() {
pbkdf2("pw", "salt", 100_000, 32, "sha512", () => {
if (++done === N) {
console.log(JSON.stringify({
ms: Date.now() - t0,
elp99: h.percentile(99) / 1e6,
pool: process.env.UV_THREADPOOL_SIZE ?? "4",
}));
process.exit(0);
} else if (done < N) kick();
});
}
for (let i = 0; i < concurrency; i++) kick();
# ✅ Sweep from outside — env must be set before node boots
for s in 4 8 16 24; do UV_THREADPOOL_SIZE=$s node dist/bench-pool.js; done
Interpret results like a senior
| Signal | Meaning |
|---|---|
| Throughput ↑, EL delay flat | Pool was undersized — take the gain |
| Throughput flat, CPU 100% | Compute-bound; pool won’t help — use workers/native |
| EL delay ↑ with larger pool | Oversubscription / context thrash — back off |
Only fs heavy |
Consider larger pool or io_uring/modern FS patterns |
// ❌ Setting UV_THREADPOOL_SIZE inside running JS — too late
process.env.UV_THREADPOOL_SIZE = "16"; // libuv already initialized
Production pinning
ENV UV_THREADPOOL_SIZE=16
CMD ["node", "dist/server.js"]
Document the bench gist link in the ECS task def PR. Re-run after Node major upgrades — pool interactions change.
Closing checklist
✅ Dos
– ✅ Confirm the API actually uses the UV pool
– ✅ Sweep sizes with event-loop delay histograms
– ✅ Set env before process start (Dockerfile/task def)
– ✅ Re-validate after Node upgrades and host size changes
– ✅ Prefer workers/native for pure CPU if pool sweeps stall
❌ Don’ts
– ❌ Don’t copy UV_THREADPOOL_SIZE=128 from a blog
– ❌ Don’t set the env var after boot
– ❌ Don’t ignore EL delay while chasing throughput
– ❌ Don’t confuse dns.resolve with dns.lookup pool behavior
Related reading
- Event Loop Blockers: Perfetto Traces That Metrics Alone Will Miss
- N-API Addons for Node: Move Hot Loops Off the Event Loop
- Node Worker Threads: SharedArrayBuffer Protocols Without Data Races
- Node DNS Caching: Stop Micro-Outages From Brief Resolver Blips
Last updated on September 11, 2026
Discover more from CheatCoders
Subscribe to get the latest posts sent to your email.
