libuv & the thread pool
Learn how Node.js uses libuv readiness notifications and a shared thread pool, while JavaScript callbacks can still block everyone.
- 01Separate handles, requests, and callbacksExplain which things stay watched, which jobs finish once, and where JavaScript runs.
- 02Predict pool pressureUse a small scheduling model to see why a bounded pool creates waiting waves.
- 03Keep a server responsiveRecognize blocking regex, JSON, and CPU work, then partition or offload it.
The waiter and the cooks
Node.js can serve many connections without making one JavaScript thread wait at every socket. libuv is the layer under Node that helps coordinate that work across operating systems. It has an event loop for callbacks, readiness notifications for network sockets, and a small thread pool for selected operations that cannot use those notifications.
libuv is a cross-platform asynchronous I/O library used by Node.js. It gives Node a common event-loop model, platform-specific network polling, and a shared worker pool for some file, DNS, crypto, and compression work.
Keep three jobs separate. The event loop runs your JavaScript callbacks. The operating system tells that loop when a network socket is ready. The thread pool runs only selected queued jobs away from the loop, then reports completion back. Async does not mean every operation uses the same path.
Imagine one waiter and four cooks. The waiter takes orders and brings food, but never cooks. Drinks from the fridge do not need a cook; the waiter sees they are ready. When all cooks are busy, a new meal waits. If the waiter cooks a huge meal, nobody else gets served.
- In real life: One waiter takes and delivers orders
- In JavaScript: The event loop runs callbacks
- In real life: Four cooks prepare queued meals
- In JavaScript: The pool handles selected jobs
- In real life: A fridge gives a drink immediately
- In JavaScript: Socket readiness comes from the OS
- In real life: The waiter starts cooking
- In JavaScript: A callback blocks the event loop
Where the analogy stops: Real libuv does not model a restaurant. The analogy only helps separate coordination, readiness, and queued work.
This continues The Node.js event loop. Here we ask a more precise question: which work stays on the loop, which work waits in the pool, and which work the operating system can notify without a pool worker?
Handles and requests
libuv describes its work with two useful words. A handle is a long-lived thing the loop watches while it is active. A server, timer, or socket can keep receiving future events. A request is one short operation, such as one file read or one write. The request completes and is then done.
const serverHandle = "watch for connections";const fileRequest = "read one file"; console.log(serverHandle);console.log(fileRequest);Line 1 labels a server-like handle. It represents something that can report another connection later. Line 2 labels a one-time file request. Lines 4 and 5 print watch for connections and read one file. The strings are intentionally plain; they teach the lifetime distinction, not the C-level libuv API.
Handles and requests can work together. A TCP handle can be watched for many connections, while a write request sends one response on that connection. Requests are normally short-lived. Handles are closed when their long-lived job ends. You do not need to memorize struct names to make a good Node design decision: ask whether it is watched repeatedly or done once.
| Name | Plain meaning | Everyday Node example |
|---|---|---|
| Handle | A long-lived watched thing | A server or timer |
| Request | One operation that completes | One file read or DNS lookup |
Network readiness notifications
Network sockets are normally non-blocking. Instead of assigning a waiting worker to every socket, libuv asks the operating system to tell it when a watched socket can read, write, or has changed state. That is why a Node server can keep many connections pending without a matching number of pool workers.
server.on("connection", (socket) => { socket.on("data", (chunk) => console.log(chunk.length));});Line 1 registers a callback and returns. Line 2 registers another callback for data that may arrive later. Line 2 prints a byte count only when data is ready. The sandbox does not open a real server, so this Node-only fragment is shown as source rather than a runnable browser example.
The platform mechanism changes, but the lesson stays the same. Linux uses epoll, macOS and other BSD systems use kqueue, and Windows uses IOCP. libuv hides those platform details behind a consistent callback model. Network I/O is performed on the loop thread; it is not a queue of socket-reading pool jobs.
Readiness means the operating system says an operation can make progress. It does not mean your callback is free. A long callback after a socket notification still blocks the event loop and delays other ready callbacks.
The thread pool
Files are different from network sockets. libuv cannot rely on one portable non-blocking file-I/O primitive across its supported systems, so it runs many file operations in a shared thread pool. The pool also accepts DNS utility work and work explicitly queued by native code. When a worker finishes, libuv posts the completion back to the loop.
const jobs = [ { name: "menu.json", duration: 3 }, { name: "orders.json", duration: 2 }, { name: "users.json", duration: 4 }, { name: "prices.json", duration: 1 }, { name: "notes.json", duration: 2 }, { name: "report.json", duration: 3 },]; console.log(schedulePoolJobs(2, jobs));Lines 1 through 8 create six short jobs with made-up durations. Line 10 asks the lesson model to schedule them with two cooks. This is not libuv source code and it does not time your disk. It is a small, deterministic way to see the one rule that matters: when every worker is busy, later pool jobs wait.
Replay an instrumented pool scheduling model. It illustrates waiting waves; it does not inspect libuv workers.
script
{ name: "menu.json", duration: 3 }, { name: "orders.json", duration: 2 }, { name: "users.json", duration: 4 }, { name: "prices.json", duration: 1 }, { name: "notes.json", duration: 2 }, { name: "report.json", duration: 3 },]; console.log(schedulePoolJobs(2, jobs));libuv documents a default pool size of 4. UV_THREADPOOL_SIZE can set it at process startup. The pool is global and shared, so increasing it changes a shared resource, increases memory use, and does not fix an event-loop callback that is doing CPU work. Measure before changing it.
const jobs = [ { name: "menu.json", duration: 3 }, { name: "orders.json", duration: 2 }, { name: "users.json", duration: 4 }, { name: "prices.json", duration: 1 }, { name: "notes.json", duration: 2 }, { name: "report.json", duration: 3 },]; console.log(schedulePoolJobs(2, jobs));cook 1: 0 to 33 units of modeled work.
cook 2: 0 to 22 units of modeled work.
cook 3: 0 to 44 units of modeled work.
cook 4: 0 to 11 units of modeled work.
cook 4: 1 to 32 units of modeled work.
cook 2: 2 to 53 units of modeled work.
prices.jsonThese requests complete together in this model.
orders.jsonThese requests complete together in this model.
menu.json, notes.jsonThese requests complete together in this model.
users.jsonThese requests complete together in this model.
report.jsonThese requests complete together in this model.
4 cooks finish all six modeled jobs at time 5. Change only the pool size, then inspect who had to wait.
Which APIs use the pool
A method being asynchronous tells you that it returns before completion. It does not tell you whether it uses operating-system readiness or a libuv worker. This distinction helps when a busy batch of work slows an unrelated operation that also needs the shared pool.
import { readFile } from "node:fs";readFile("menu.json", "utf8", (error, text) => console.log(error ?? text));console.log("request submitted");Line 2 submits one file request. Line 3 can print request submitted before the read callback eventually logs a result. The order between real I/O completion and other events is not a teaching promise, so this Node-only source has no fixed output claim.
| Work | Usually coordinated by | Why it matters |
|---|---|---|
| Network sockets | OS readiness notification | epoll, kqueue, or IOCP tells the loop a socket is ready. |
Most async fs APIs | libuv thread pool | File operations do not have portable non-blocking primitives. |
dns.lookup | libuv thread pool | It uses operating-system name lookup. |
Async crypto and zlib | libuv thread pool | Examples include pbkdf2, scrypt, randomBytes, and compression. |
| Your callback | Event loop | JavaScript runs until it returns or yields. |
The documented pool users include most asynchronous fs APIs, dns.lookup, and asynchronous zlib APIs. Selected asynchronous crypto APIs also use it, including pbkdf2, scrypt, and asynchronous randomBytes. Network sockets use operating-system readiness notifications instead.
- A network socket becomes readable.
- An async file read.
dns.lookup("example.com").- A completed timer callback.
- Async
crypto.pbkdf2. - A
whileloop in a request handler.
Sort each card by the mechanism that first makes it runnable. Then read the explanation.
JavaScript can still block
The thread pool does not run your ordinary JavaScript callback for you. Once Node calls a callback, JavaScript runs synchronously until it returns. A long loop, a huge parse, or expensive regular-expression work makes the event loop unavailable to timers, incoming socket work, and every other callback waiting for a turn.
setTimeout(() => console.log("timer"), 0);const end = Date.now() + 30;while (Date.now() < end) {}console.log("busy work done");Line 1 schedules a timer. Line 2 chooses an end time. Line 3 stays in synchronous JavaScript until that time, so line 4 prints busy work done first. Only after the current script returns can the timer callback print timer. The same small browser-runnable code teaches the same rule in a web page.
Do not turn this into a timing assertion. The lesson only promises order: busy JavaScript finishes before the timer callback gets a turn. In a server, a hostile request can exploit a callback whose cost grows with input size. This is a responsiveness problem and a denial of service risk, even though the process uses asynchronous I/O elsewhere.
ReDoS and JSON DoS
A regular expression denial of service, or ReDoS, happens when a regular expression can take far too long for a crafted input. Nested quantifiers are a warning sign. The pattern /^(a+)+$/ can repeatedly reconsider many ways to group a failing string of a characters ending in !. Each extra character can double the work in a vulnerable case.
const input = "aaaa!";console.log(/^(a+)+$/.test(input));Line 1 deliberately stays small so the browser sandbox finishes quickly. Line 2 prints false, because the final exclamation mark breaks the pattern. The danger is not this tiny input; it is accepting an unbounded hostile string and assuming the match will always be quick.
JSON denial of service is simpler: JSON.parse and JSON.stringify are synchronous. Their work is linear in input length, but a huge body can still occupy the event loop for a noticeable time. Limit request-body size before parsing, keep schemas simple, and move genuinely expensive processing out of the callback.
const body = '{"user":"Asha"}';console.log(body.length <= 100 ? JSON.parse(body).user : "too large");Line 1 is a small JSON body. Line 2 checks the length before parsing and prints Asha. A real server should enforce the limit while reading the request, not after it has already stored an enormous body in memory.
Partitioning and offloading
Some work is simple but long: summing a long list, building a small report, or processing rows. Partitioning means doing a small chunk, saving progress, and yielding before the next chunk. That lets the loop process other pending work between chunks. It does not create a second JavaScript CPU core.
function sumInChunks(numbers, done) { let index = 0; let total = 0; function nextChunk() { const stop = Math.min(index + 2, numbers.length); while (index < stop) total += numbers[index++]; if (index < numbers.length) return setImmediate(nextChunk); done(total); } nextChunk();} setTimeout(() => console.log("timer"), 0);sumInChunks([1, 2, 3, 4], (total) => console.log("sum", total));Line 5 picks two numbers for this chunk. Line 6 processes only those two. Line 7 uses setImmediate to return control before the next chunk. The timer at line 12 can get a turn between chunks. This Node-only example is tested for order, not for milliseconds.
For complex CPU work, use worker_threads or a dedicated worker pool. A worker has its own JavaScript execution context, so it can use another core. Passing data has a cost: values are copied or transferred, and the result still returns to the event loop for the response. Offloading is not free, but it protects the main callback from doing the whole calculation.
import { Worker } from "node:worker_threads"; const worker = new Worker(new URL("./price-worker.mjs", import.meta.url));worker.postMessage({ prices: [3, 4, 5] });worker.once("message", (total) => console.log(total));Line 1 imports Node's worker API. Line 3 creates one worker, line 4 sends ordinary data, and line 5 receives a total later. Do not use the libuv I/O pool as a general JavaScript compute pool; use workers for CPU-bound application code.
Practical server choices
Start by making request callbacks small and input sizes bounded. Async file, crypto, DNS, and zlib work can still queue behind one another in the shared pool. A slow pool task reduces capacity for the next pool user. A slow JavaScript callback blocks every callback on that event loop, including work whose I/O already finished.
- Use asynchronous file and crypto APIs in request paths; avoid synchronous variants.
- Put limits on request bodies, regex input, work size, and cache growth.
- Measure queueing and CPU before changing
UV_THREADPOOL_SIZE. - Partition simple loops; use
worker_threadsfor sustained CPU work. - Keep socket callbacks short after the OS says a socket is ready.
This is why Node works well for I/O-heavy services: one loop can coordinate many waiting sockets. It still needs fairness from your code. A request handler that does too much work for one client makes every other client wait for the same loop or pool resource.
Common misconceptions
- “Async means a new thread.” Many network operations wait through OS readiness notifications, not a worker.
- “The pool runs my callback.” The callback returns to the event loop after the pool job completes.
- “More pool workers fix slow JavaScript.” A busy
while, regex, or parse still blocks the loop. - “A timer of zero runs immediately.” It runs only after the current JavaScript work gives the loop a turn.
- “ReDoS needs a giant regex.” A short nested pattern with unbounded hostile input can be enough.
| Term | What it means | Not the same as |
|---|---|---|
| Asynchronous | The caller can continue while work is pending. | Guaranteed not to use a thread pool. |
| Non-blocking network I/O | The OS reports a socket ready to read or write. | A worker reading every socket. |
| Thread pool | A bounded shared pool for selected work. | One new thread for every request. |
UV_THREADPOOL_SIZE | A startup setting for the global libuv pool. | A cure for slow JavaScript callbacks. |
Practice exercises
Type the first line printed by this program.
setTimeout(() => console.log("timer"), 0);
const end = Date.now() + 30;
while (Date.now() < end) {}
console.log("busy work done");busy work done prints first. The loop cannot run the scheduled timer callback while line 3 is busy.
A server accepts another connection later. Is it a handle or a request?
const handle = "timer";
const request = "one file read";
console.log(handle, request);A server is a handle. A single file read is a request because it completes once.
Predict the comma-separated finish times from this one-cook model.
console.log(schedulePoolJobs(1, [{ name: "a", duration: 2 }, { name: "b", duration: 1 }]).map((job) => job.finish).join(","));It prints 2,3: the second job starts only after the single modeled cook becomes free.
What does the safe expression print?
const input = "aaaa!";
console.log(/^a+!$/.test(input));It prints true. Prefer simple patterns and input limits instead of complex, unbounded validation regexes.
Your product-import endpoint accepts JSON from outside your company. Name one protection before parsing a body.
Limit the request or body size. A bound lets you reason about the largest amount of JSON parsing one callback can do.
A dashboard creates a heavy image report for each customer. How should that CPU-heavy work be handled?
Offload it with worker_threads or a dedicated worker setup. The event loop can coordinate the job and respond when the worker returns.
Check your understanding
Separate the three paths every time: callback on the loop, socket readiness from the OS, or selected work queued to the pool.
Question 1 of 7What does the event loop do while this synchronous loop runs?
Read the code, then predictsetTimeout(() => console.log("timer"), 0); const end = Date.now() + 1; while (Date.now() < end) {} console.log("done");Choose an answer to see the explanation.
Question 2 of 7Which is a long-lived handle in plain language?
Choose an answer to see the explanation.
Question 3 of 7Which mechanism usually reports that a network socket is ready?
Choose an answer to see the explanation.
Question 4 of 7What is libuv's default thread-pool size?
Choose an answer to see the explanation.
Question 5 of 7Which API is documented to use the pool?
Choose an answer to see the explanation.
Question 6 of 7Why can
/^(a+)+$/be dangerous for hostile input?Choose an answer to see the explanation.
Question 7 of 7When is partitioning a useful first option?
Choose an answer to see the explanation.
Key takeaways
- Handles are long-lived watched things; requests are one short operation.
- Network sockets use operating-system readiness notifications such as epoll, kqueue, and IOCP.
- libuv's shared pool defaults to four workers and queues selected file, DNS, crypto, and zlib work.
- Blocking JavaScript, ReDoS, and huge JSON parsing still delay the event loop.
- Yield simple work in chunks and offload sustained CPU work to Node workers.
Remember the one-liner.
Node can wait for I/O efficiently, but every callback and queued worker job must still finish fairly.
Coming next: Browser architecture, where a browser separates work across processes and threads.