Thread Pool Starvation — Why Node.js Blocks Even in Async Code
Advertisement
Introduction
You've written Node.js code that looks perfectly async. await db.query(), await fs.promises.readFile(), await bcrypt.hash(). No synchronous blocking. Yet under load, responses start stalling — some requests take 5x longer than expected.
The culprit is the libuv thread pool — Node.js's hidden worker threads that handle certain "async" operations synchronously under the hood.
- Node.js's Event Loop and libuv Thread Pool
- Diagnosing Thread Pool Starvation
- Fix 1: Increase Thread Pool Size
- Fix 2: Use Worker Threads for CPU-Heavy Work
- Fix 3: Use argon2 Instead of bcrypt
- Fix 4: Fix DNS Resolution Starvation
- Fix 5: Use Streams for File Operations
- Thread Pool Monitoring
- Summary: libuv Thread Pool Consumers
- Conclusion
Node.js's Event Loop and libuv Thread Pool
Node.js uses a single-threaded event loop for JavaScript, but libuv maintains a thread pool (default: 4 threads) for operations the OS can't make truly async:
JavaScript Event Loop (1 thread)
│
├── Network I/O (OS async → no thread pool needed)
├── Timers (setTimeout, setInterval → no thread pool)
│
└── libuv Thread Pool (4 threads by default)
├── fs.* (file system operations)
├── crypto.* (hashing, encryption)
├── dns.lookup() (DNS resolution)
└── C++ addons using thread pool
If you have 100 concurrent bcrypt.hash() calls and only 4 thread pool threads — 96 of them wait in a queue.
Diagnosing Thread Pool Starvation
// Signs of thread pool starvation:
// 1. DNS lookups slow down (even though network is fine)
// 2. fs.readFile takes seconds instead of ms
// 3. bcrypt.hash slows down under concurrent load
// 4. Event loop lag despite "async" code
// Measure thread pool queue directly
import { monitorEventLoopDelay } from 'perf_hooks'
const histogram = monitorEventLoopDelay({ resolution: 10 })
histogram.enable()
setInterval(() => {
console.log({
p50: histogram.percentile(50) / 1e6 + 'ms',
p99: histogram.percentile(99) / 1e6 + 'ms',
max: histogram.max / 1e6 + 'ms',
})
histogram.reset()
}, 5000)
// If p99 > 100ms despite no CPU-heavy JavaScript → thread pool starvation
# Benchmark bcrypt under concurrency to expose starvation
node -e "
const bcrypt = require('bcrypt');
const start = Date.now();
Promise.all(
Array.from({length: 100}, () => bcrypt.hash('password', 10))
).then(() => console.log('100 concurrent bcrypt:', Date.now() - start, 'ms'));
"
# With 4 threads: ~25x slower than single call!
Fix 1: Increase Thread Pool Size
// Set before any other imports
process.env.UV_THREADPOOL_SIZE = '64' // Max: 1024
// Or in your start command:
// UV_THREADPOOL_SIZE=64 node app.js
But: more threads = more memory and context switching. Don't blindly set to 1024. Rule of thumb: UV_THREADPOOL_SIZE = number of CPU cores × 4 for crypto-heavy apps.
Fix 2: Use Worker Threads for CPU-Heavy Work
Move heavy cryptographic work off the thread pool:
import { Worker, isMainThread, parentPort, workerData } from 'worker_threads'
import bcrypt from 'bcrypt'
if (!isMainThread) {
// Worker code
const { password, saltRounds } = workerData
bcrypt.hash(password, saltRounds).then(hash => {
parentPort!.postMessage({ hash })
})
}
// Main thread: hash passwords in worker pool
import { WorkerPool } from './worker-pool'
const cryptoPool = new WorkerPool('./crypto-worker.js', {
numWorkers: 4,
})
async function hashPassword(password: string): Promise<string> {
const { hash } = await cryptoPool.run({ password, saltRounds: 10 })
return hash
}
// WorkerPool implementation
import { Worker } from 'worker_threads'
import { Queue } from './queue'
class WorkerPool {
private workers: Worker[] = []
private queue: Queue<Task> = new Queue()
constructor(private workerPath: string, { numWorkers }: { numWorkers: number }) {
for (let i = 0; i < numWorkers; i++) {
this.addWorker()
}
}
private addWorker() {
const worker = new Worker(this.workerPath)
worker.on('message', result => {
const task = this.queue.dequeue()
if (task) {
task.resolve(result)
worker.postMessage(task.data)
} else {
// Worker is idle
this.idle.add(worker)
}
})
this.idle.add(worker)
}
private idle = new Set<Worker>()
run(data: any): Promise<any> {
return new Promise((resolve, reject) => {
const idleWorker = this.idle.values().next().value
if (idleWorker) {
this.idle.delete(idleWorker)
idleWorker.once('message', resolve)
idleWorker.postMessage(data)
} else {
this.queue.enqueue({ data, resolve, reject })
}
})
}
}
Fix 3: Use argon2 Instead of bcrypt
argon2 uses a Node.js addon that doesn't consume the libuv thread pool — it's handled differently and is also more secure:
npm install argon2
import argon2 from 'argon2'
// ✅ More secure AND doesn't exhaust thread pool
async function hashPassword(password: string): Promise<string> {
return argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 65536, // 64MB
timeCost: 3,
parallelism: 4,
})
}
async function verifyPassword(hash: string, password: string): Promise<boolean> {
return argon2.verify(hash, password)
}
Fix 4: Fix DNS Resolution Starvation
dns.lookup() is the most common and invisible thread pool consumer — it runs on every HTTP request you make:
// ❌ dns.lookup() — uses thread pool, blocks under load
import http from 'http'
http.get('http://api.example.com/data') // Internally calls dns.lookup()
// ✅ dns.resolve() — uses the OS async DNS resolver, NO thread pool
import dns from 'dns'
dns.setDefaultResultOrder('ipv4first')
dns.promises.resolve4('api.example.com') // Async, no thread pool!
// ✅ Or use lookup with the 'hints' option to bypass thread pool
import { lookup } from 'dns'
lookup('api.example.com', { hints: dns.ADDRCONFIG }, callback)
// ✅ Best fix: cache DNS resolutions
import { Resolver } from 'dns/promises'
import LRU from 'lru-cache'
const resolver = new Resolver()
const dnsCache = new LRU<string, string>({ max: 1000, ttl: 60_000 })
async function resolveHostname(hostname: string): Promise<string> {
const cached = dnsCache.get(hostname)
if (cached) return cached
const [ip] = await resolver.resolve4(hostname)
dnsCache.set(hostname, ip)
return ip
}
Fix 5: Use Streams for File Operations
Large file reads block thread pool threads for longer:
// ❌ Reads entire file — thread pool thread busy for duration
const content = await fs.promises.readFile('huge-file.csv')
processCSV(content)
// ✅ Stream — releases thread pool thread immediately after each chunk
import { createReadStream } from 'fs'
import { createInterface } from 'readline'
const rl = createInterface({ input: createReadStream('huge-file.csv') })
for await (const line of rl) {
await processLine(line) // Process one line at a time
}
Thread Pool Monitoring
// Track thread pool queue depth indirectly via event loop delay
const diagnostics_channel = await import('diagnostics_channel')
// Node.js 18+ performance hooks
const obs = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration > 50) {
console.warn(`${entry.name} took ${entry.duration.toFixed(1)}ms — possible thread pool queue`)
}
}
})
obs.observe({ entryTypes: ['function', 'gc', 'measure'] })
Summary: libuv Thread Pool Consumers
| Operation | Thread Pool? | Fix |
|---|---|---|
bcrypt.hash() | ✅ Yes | argon2 or worker threads |
crypto.pbkdf2() | ✅ Yes | crypto.pbkdf2Sync in worker or argon2 |
fs.readFile() | ✅ Yes | Streams or increase pool size |
dns.lookup() | ✅ Yes | dns.resolve() + cache |
http/https (network) | ❌ No | Safe — uses OS async |
| DB queries (pg, mysql2) | ❌ No | Safe — use own connection pool |
setTimeout/setInterval | ❌ No | Safe |
Conclusion
Thread pool starvation is Node.js's most surprising performance trap because the code looks async but secretly isn't. The fixes are targeted: increase UV_THREADPOOL_SIZE for a quick fix, switch bcrypt to argon2, cache DNS resolutions, and use worker threads for CPU-heavy crypto work. Measure event loop delay to catch starvation early — it's the most reliable indicator that something is queuing in the thread pool.
Advertisement