System theme
Node.js

Node.js Is Multi-Threaded: How libuv Runs Your Blocking Work

The event loop is single-threaded, but Node.js offloads blocking operations to a libuv thread pool. Here's how it works, what uses it, and how to tune it.

KK13 · 2 min read

Node.js markets itself as single-threaded. That's true for your JavaScript — the event loop runs on one thread. But the runtime itself is multi-threaded. libuv, the C library that powers Node's asynchronous I/O, maintains a thread pool for operations that would otherwise block the event loop.

What libuv's thread pool actually does

When you call fs.readFile, crypto.pbkdf2, or dns.lookup, libuv doesn't run them on the event loop. It submits a work request to the thread pool, the pool executes the blocking syscall, and libuv posts a completion callback back to the event loop.

The pool size defaults to 4 threads. You can change it at process start:

UV_THREADPOOL_SIZE=16 node server.js

Or in code before any async work runs:

process.env.UV_THREADPOOL_SIZE = '16';

Operations that use the thread pool

Not all async work goes to the pool. Network I/O (TCP, UDP, pipes) uses epoll/kqueue/IOCP directly on the event loop thread. The thread pool handles:

Category Examples
File system fs.readFile, fs.writeFile, fs.stat, fs.readdir
DNS dns.lookup (but not dns.resolve)
Crypto crypto.pbkdf2, crypto.scrypt, crypto.randomBytes (large), crypto.generateKeyPair
Compression zlib deflate/inflate (large payloads)
User code worker_threads (separate V8 isolates, not the libuv pool)

Seeing the pool in action

// pool-demo.js
const fs = require('fs');
const crypto = require('crypto');
const { performance } = require('perf_hooks');

const start = performance.now();

// Fire 8 concurrent crypto operations — pool size is 4 by default
const promises = Array.from({ length: 8 }, (_, i) =>
  new Promise((resolve) => {
    crypto.pbkdf2('secret', 'salt', 100000, 64, 'sha512', () => {
      resolve(i);
    });
  })
);

Promise.all(promises).then(() => {
  console.log(`8 pbkdf2 calls took ${(performance.now() - start).toFixed(0)} ms`);
});

Run it twice:

# Default pool (4 threads)
node pool-demo.js
# 8 pbkdf2 calls took ~2400 ms (2 batches of 4)

UV_THREADPOOL_SIZE=8 node pool-demo.js
# 8 pbkdf2 calls took ~1200 ms (1 batch of 8)

The time halves when the pool matches the concurrency.

When to increase the pool

  • Heavy crypto workloads — PBKDF2, scrypt, key generation
  • Batch file processing — reading/writing hundreds of files in parallel
  • CPU-bound work you can't move to worker_threads — though worker_threads is usually the better answer

Don't increase it blindly. More threads = more context switching, more memory, more contention on the event loop when callbacks fire. Profile first.

Common misconceptions

Myth: "Node.js is single-threaded so it can't use multiple cores."
The event loop is single-threaded. The process is not. libuv's pool, worker_threads, and cluster all use multiple cores.

Myth: "All async I/O uses the thread pool."
Network sockets use the OS's async interfaces (epoll, kqueue, IOCP) directly on the event loop thread. Only the operations listed above use the pool.

Myth: "Increasing UV_THREADPOOL_SIZE makes everything faster."
Only work that actually runs in the pool benefits. Network throughput won't change. Crypto and heavy FS will.

Debugging pool saturation

Node 18+ exposes libuv metrics via perf_hooks:

const { PerformanceObserver, performance } = require('perf_hooks');

const obs = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.name === 'threadpool') {
      console.log(entry.detail); // { threads: 4, idle: 0, queued: 12 }
    }
  }
});
obs.observe({ entryTypes: ['threadpool'] });

This shows real-time pool utilisation — useful for sizing UV_THREADPOOL_SIZE in production.

Summary

  • Node.js JavaScript runs on one thread (the event loop).
  • libuv runs a separate thread pool (default 4) for blocking syscalls.
  • FS, some DNS, crypto, and zlib use the pool; network I/O does not.
  • Tune UV_THREADPOOL_SIZE at startup for workloads that saturate the pool.
  • For CPU-bound application code, use worker_threads instead.

Next in Node.js

Node.js I/O Model and Its Strengths for Realtime Applications

Explains Node.js non‑blocking I/O and why it suits realtime, low‑latency applications.

20 Sept 2026 · 2 min read