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

Sanjeev SharmaSanjeev Sharma
6 min read

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

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.

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro