libuv thread pool
Six PBKDF2 jobs reveal the native thread-pool queue and the callbacks returning to the Event Loop.
Timeline
Understanding: libuv thread pool
You can read this chapter before running the experiment. Then return to the live trace and match each concept to a real event.
Think of a small kitchen behind the dining room. The JavaScript waiter quickly submits six orders. Only four cooks are available, so two orders wait even though the waiter is free to serve other guests.
The libuv pool executes selected native operations that cannot use normal OS readiness or are computationally expensive. Application JavaScript does not execute in this pool. The pool is shared by the process, and completed native work posts a callback back to the Event Loop.
Terms used in this experiment
Understand the words first, then the execution order.
Native operation
C/C++ code inside Node or a library, rather than your application JavaScript.
Thread pool
A fixed number of reusable threads with a queue of jobs in front of them.
UV_THREADPOOL_SIZE
An environment variable that sets shared pool size when the Node process starts.
PBKDF2
A computationally expensive key-derivation function used here to create observable pool work.
What happens step by step
Each step maps to an observable runtime state.
- 01Six submissions
JavaScript quickly calls async pbkdf2 six times.
- 02Pool queue
Free native threads take jobs; the remaining jobs wait.
- 03Main is free
The Event Loop can handle HTTP while native threads calculate.
- 04First wave
With the default pool, roughly four jobs often complete close together.
- 05Result callbacks
Each native completion returns to the main Event Loop.
Where the result needs context
These details explain why similar code can sometimes produce a different trace.
Pool size is not physical core count
UV_THREADPOOL_SIZE=4 permits up to four pool jobs, but the OS and CPU decide how much work truly runs at once. A busy or low-core machine may not show clean waves.
Set the variable before Node starts
The pool is initialized early, so pass UV_THREADPOOL_SIZE in the process or container environment. Changing process.env later is not a reliable live reconfiguration.
A free Event Loop can still be slow
Main does not calculate PBKDF2, but native threads compete for the same CPU. A saturated shared pool also delays unrelated fs, crypto, and some DNS operations that use it.
Waves are an observation, not a contract
Equal iteration counts do not guarantee strict completion order. OS scheduling, CPU frequency, and other load can mix the callbacks.
First understand which parts of Node participate in execution.
Then remove instrumentation and focus on the central mechanism.
Finally match the model to the code that produces the live trace.
A minimal model without instrumentation
for (let i = 0; i < 6; i++) {
crypto.pbkdf2('secret', 'salt', 120_000, 32, 'sha256',
() => console.log('ready', i)
);
}The complete code executed by the scenario
This is not an alternative example: these are the functions and files used by the Run button.
The source is generated from the real server function. Scenarios using a child process or Worker include every participating file.
import { pbkdf2 } from 'node:crypto';
import { performance } from 'node:perf_hooks';
async function libuvThreadPool(emit) {
const jobs = 6;
const iterations = 120_000;
const startedAt = performance.now();
emit(
'call-stack',
'sync',
`Синхронно запускаем ${jobs} вызовов crypto.pbkdf2`,
);
emit(
'libuv',
'info',
`UV_THREADPOOL_SIZE=${process.env.UV_THREADPOOL_SIZE ?? '4 (по умолчанию)'}`,
);
const tasks = Array.from({ length: jobs }, (_, index) => {
const job = index + 1;
emit('libuv-queue', 'schedule', `PBKDF2 #${job} отправлен в пул`);
return new Promise((resolve, reject) => {
pbkdf2('node-loop-lab', `salt-${job}`, iterations, 32, 'sha256', (error) => {
if (error) {
reject(error);
return;
}
emit(
'poll',
'callback',
`PBKDF2 #${job} вернулся через ${Math.round(performance.now() - startedAt)} мс`,
);
resolve();
});
});
});
emit(
'call-stack',
'info',
'Главный JS-поток сразу свободен; вычисления идут в пуле libuv',
);
await Promise.all(tasks);
emit(
'result',
'result',
`Все ${jobs} задач завершены за ${Math.round(performance.now() - startedAt)} мс`,
);
}The emit(...) calls become live-trace rows. await and Promise keep the HTTP stream open until the scenario completes.
Common misconceptions
The myth is on the left; the accurate model is on the right.
The libuv pool and Worker Threads are the same.
The pool runs native API functions; a Worker runs your JavaScript in another isolate.
A larger UV_THREADPOOL_SIZE is always faster.
After CPU saturation, more threads add contention and context switching.
Every asynchronous Node API uses this pool.
Network sockets usually use OS readiness without one thread per operation.
A free main thread means the load cannot affect HTTP.
CPU contention and a saturated shared pool can still increase latency even when JavaScript is not blocked.
Explain it in your own words
If you can explain the answer without quoting documentation, your mental model is starting to take shape.
- Why can job #5 wait while the Event Loop is free?
- What happens to fs.readFile when the shared pool is fully occupied?