- Published on
Thread Pool Starvation — Why Node.js Blocks Even in Async Code
- Authors

- Name
- Sanjeev Sharma
- @webcoderspeed1
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.