Published on

Thread Pool Starvation — Why Node.js Blocks Even in Async Code

Authors

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

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

OperationThread Pool?Fix
bcrypt.hash()✅ Yesargon2 or worker threads
crypto.pbkdf2()✅ Yescrypto.pbkdf2Sync in worker or argon2
fs.readFile()✅ YesStreams or increase pool size
dns.lookup()✅ Yesdns.resolve() + cache
http/https (network)❌ NoSafe — uses OS async
DB queries (pg, mysql2)❌ NoSafe — use own connection pool
setTimeout/setInterval❌ NoSafe

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.