- Published on
- 13 min read
How Node.js handles many requests with one thread
- Authors

- Name
- Nazarii Mural
- @NazariiMural
The problem, in numbers
Say you have one endpoint. It saves a user to the database. It checks the input, writes one row, sends a response back. One request takes 100 ms from start to finish.
Of those 100 ms, the database call takes 90. The other 10 ms is your JavaScript.
So for 90 ms out of every 100, your CPU has nothing to do. It is waiting for a machine somewhere else to answer. If Node.js stopped and waited too, this server would handle 10 requests per second and spend 90% of its life idle.
It does not stop and wait. One thread runs your JavaScript, and that same server can handle hundreds of these requests at the same time. This post is about what happens in those 90 ms.
I wrote about the event loop and its phases in an earlier post. That post picks up when a callback is already sitting in a queue. This one is about everything that happens before it gets there.
Node.js is more than JavaScript
When people say Node.js is single-threaded, they mean one specific thing. Your JavaScript runs on one thread, called the main thread. Two lines of your code never run at the same moment.
But Node.js is not only V8, the engine that runs the JavaScript. A large part of it is C and C++. The piece that matters here is libuv, a C library that does the input and output work. Reading files, opening sockets, looking up host names. libuv has threads. It also talks to the operating system, and the operating system has plenty of threads of its own.
So the honest answer to "is Node.js single-threaded" is that your code is, and the process is not.
Asynchronous does not mean parallel
This is the idea most people get wrong, and everything else depends on it.
An asynchronous operation is not an operation that runs somewhere else at the same time. It is an operation that will not answer now, and will answer later.
When you write await db.save(user), nothing about that sentence promises a second worker. It promises that your function will pause and that something will wake it up when the answer arrives. Whether anyone is actually working during the pause is a separate question, and it has two different answers.
Two kinds of waiting
Some work needs someone to sit and wait for it. Some does not.
blocking non-blocking
──────── ────────────
libuv takes a thread from libuv asks the operating system
its pool and that thread to watch the socket, then walks
sits there until the work away. Nobody waits. The OS taps
is finished. Node.js on the shoulder later.
costs one thread costs no thread
pool has 4 by default limited only by memory
Reading a file is the blocking kind. There is no way to ask the disk to call you back, so a thread has to do the read and block while it happens. Waiting for a TCP socket is the non-blocking kind. Every operating system has a way to watch thousands of sockets at once and report which ones are ready.
Most of what a web server waits for is the second kind. That is why one thread is enough.
Three short programs make this concrete. You can run all of them yourself. The numbers below come from a machine with Node.js v24.16.0 and libuv 1.52.1 on an Apple M4 Pro, so your own numbers will differ. The pattern will not.
Experiment one: the thread pool is real
pbkdf2 is a password hashing function. Its asynchronous form is one of the rare Node.js calls that is both asynchronous and blocking, so it takes a thread from the pool and holds it.
import { pbkdf2 } from 'node:crypto';
const start = Date.now();
for (const run of [1, 2]) {
pbkdf2('secret', 'salt', 1e7, 64, 'sha512', () => {
console.log(`run ${run} finished after ${(Date.now() - start) / 1000}s`);
});
}
UV_THREADPOOL_SIZE sets how many threads libuv starts. Run the file with one thread, then with two.
$ UV_THREADPOOL_SIZE=1 node blocking.mjs
run 1 finished after 1.728s
run 2 finished after 3.452s
$ UV_THREADPOOL_SIZE=2 node blocking.mjs
run 1 finished after 1.794s
run 2 finished after 1.803s
With one thread the two hashes run one after the other, and the second one finishes at double the time. With two threads they run together. The pool exists, and it is the thing deciding the order.
Experiment two: network work ignores the pool
Now the same shape of test, but with two HTTP requests instead of two hashes.
const start = Date.now();
for (const run of [1, 2]) {
fetch('https://example.com').then(() => {
console.log(`fetch ${run} returned after ${(Date.now() - start) / 1000}s`);
});
}
$ UV_THREADPOOL_SIZE=1 node non-blocking.mjs
fetch 1 returned after 0.071s
fetch 2 returned after 0.072s
$ UV_THREADPOOL_SIZE=8 node non-blocking.mjs
fetch 1 returned after 0.075s
fetch 2 returned after 0.076s
Eight times the threads, same result. I ran each size three times and every run landed between 0.069s and 0.076s, apart from two slower runs around 0.13s, where both fetches slowed down together.
Giving libuv more threads does nothing for network work, because network work never asked for a thread.
Experiment three: what actually competes with what
The first two experiments each show one behaviour. This one shows the whole picture in a single program, and it is where the surprise lives.
The default pool has 4 threads. So start 4 slow hashes to fill every one of them. Then, at the same moment, do three things that all look like waiting: read a small file, open a TCP connection to an IP address, and run a fetch.
import { pbkdf2 } from 'node:crypto';
import { readFile } from 'node:fs';
import { connect } from 'node:net';
const busy = Number(process.argv[2] ?? 0);
const start = Date.now();
const since = () => `${(Date.now() - start) / 1000}s`;
// Fill every thread in the pool with slow CPU work.
for (let i = 0; i < busy; i += 1) {
pbkdf2('secret', 'salt', 1e7, 64, 'sha512', () => {});
}
readFile(new URL(import.meta.url), () =>
console.log(` file read done at ${since()}`),
);
connect({ host: '1.1.1.1', port: 443 }, function onConnect() {
console.log(` tcp connect done at ${since()}`);
this.destroy();
});
fetch('https://example.com').then(() =>
console.log(` fetch done at ${since()}`),
);
With an empty pool, everything is quick.
$ node competition.mjs 0
file read done at 0.015s
tcp connect done at 0.027s
fetch done at 0.071s
Now fill the pool.
$ node competition.mjs 4
tcp connect done at 0.027s
file read done at 2.065s
fetch done at 2.115s
Read that output slowly, because there are three separate lessons in it.
The TCP connection finished at 0.027s in both runs. Four busy threads changed nothing, because opening a socket never needed a thread. The operating system was watching it.
The file read went from 0.015s to 2.065s. It waited two full seconds for a thread to come free. Nothing was wrong with the disk. The pool was simply full.
And fetch went from 0.071s to 2.115s, which is the part people do not expect. fetch is network work, so it should behave like the TCP connection. It does, except for the first step. Before it can open a socket it has to turn example.com into an IP address, and Node.js does that with dns.lookup, which uses the thread pool. The lookup got stuck in the queue, and the whole request got stuck behind it.
That is the practical lesson of the whole post. A CPU-heavy call in one part of your server can delay file reads and outgoing HTTP requests in a completely unrelated part, and the stack trace will tell you nothing about why.
Only four kinds of work use the pool
Node.js sends these to the libuv thread pool.
- Everything in
fs, except theSyncversions dns.lookup, which is also whatfetch,httpand most database drivers use to resolve host names- Some of
crypto, namelypbkdf2,scrypt,randomBytes,randomFillandgenerateKeyPair - All of
zlib
dns.resolve is the exception worth remembering. It sends a real DNS query over the network, so it skips the pool. dns.lookup asks the operating system, and the call that does that blocks, so it needs a thread.
Everything else in Node.js either runs on the main thread or is handed to the operating system.
So who waits for the network?
If no thread is waiting for a socket, something has to be. That something is the kernel. Getting good at this took operating systems about thirty years, and the story is short enough to tell.
One thread per connection, the old way
The first way to write a server was to give every connection its own thread. A connection arrives, you start a thread, that thread reads and writes until the client goes away.
This works. It also means that 20,000 connections cost 20,000 threads, and each thread costs memory and scheduler time even while it is doing nothing but waiting. That does not scale.
The insight that fixed it is that a waiting connection is not much data. It is a small number and a note about what to do when something happens on it.
What that small number is
The kernel keeps a table for every process. Each row in that table describes one open thing, such as a file, a socket or a device. The row holds the type, the current read position, the permissions, and a pointer to the kernel's own data for it.
A file descriptor is the row number. That is all it is. An integer index into that table. When you open a file and the kernel hands back 7, it means row 7 of your table.
On Windows the same idea exists under the name handle. The mechanics differ, the concept does not.
So instead of one thread per connection, a server can hold a list of integers and ask the kernel one question. Which of these are ready?
select, and why it stopped being enough
select was the first widely used answer. You pass it a list of descriptors, it blocks until at least one is ready, and then you walk the list to find out which.
You walk the list. Every time. With 10,000 descriptors you check 10,000 of them to find the 3 that are ready, and you do it again on the next turn. The cost grows with the number of connections you are watching, not with the number that actually did something. select also had a hard limit on how many descriptors it could handle at all.
epoll, kqueue and IOCP
The fix is to tell the kernel once which descriptors you care about, and let the kernel hand back only the ready ones.
On Linux this is epoll. You create an instance with epoll_create, register descriptors with epoll_ctl, and then call epoll_wait. The registration happens once. The wait returns only what is ready, so the cost now tracks the number of events instead of the number of connections. epoll_wait also takes a timeout, which is exactly what the event loop needs so it can wake up in time for the next setTimeout.
macOS and the BSDs have kqueue. Windows has I/O completion ports. libuv uses whichever one it finds and hides the difference from you. This is most of what libuv is for.
The event loop is a loop around that one call. Ask the kernel what is ready, run the callbacks for those things, ask again. When nothing is left to wait for, the loop ends and the process exits.
io_uring, and where it stands today
io_uring is the newer Linux design. It does not make the old question faster, it removes the question. Instead of asking what is ready and then acting, the process puts requests into a queue that it shares with the kernel, and the kernel puts results into a second shared queue. Two rings, one for work going in and one for results coming out. No asking.
Here is the part that is worth knowing, because a lot of articles still get it wrong.
That is a good reason to trust experiment three over any article, including this one. Run it on your own machine and see what your own Node.js does.
The whole path of one request
Putting it together, here is what happens between the client connecting and the client getting bytes back.
your JavaScript libuv operating system
─────────────── ───── ────────────────
app.post('/users')
│
│ db.save(user)
├──────────────────────▶ is this call
│ blocking?
│ │
│ no ───────┴─────── yes
│ │ │
│ ▼ ▼
│ register the take a thread
│ socket with ◀── from the pool
│ epoll / kqueue and block on it
│ │ │
▼ │ │
returns straight away │ │
main thread is free to │ │
run the next request │ │
▼ ▼
the kernel says the thread finishes
the socket is and reports back
ready to the loop
│ │
└────────┬─────────┘
▼
the callback joins a queue
│
▼
the event loop runs it on
the main thread, in its turn
The callback queues and the order they run in are the subject of the event loop post.
Two requests at the same time
Now the original question has a boring answer, which is the sign that the explanation worked.
Two requests arrive. The kernel accepts both sockets and tells the event loop about both. The loop runs the first route handler on the main thread. That handler reaches db.save(user), which hands a socket to the kernel and returns immediately. The handler is now paused and the main thread is free, so the loop runs the second route handler. It does the same.
Both requests are now waiting. No thread belongs to either of them. Whichever database answer arrives first gets its callback queued first, and the loop runs them in the order they arrive.
Node.js never ran the two handlers at the same instant. It did not need to. It only needed to never be the one doing the waiting.
What to do with this
The rule that follows is short. Never make the main thread wait.
Every readFileSync, every JSON.parse of a 40 MB payload, every long loop over a big array is time when your server answers nobody. Not the request it is working on, and not the other 200. The 10 ms of CPU in the example at the top of this post is your whole budget per request. Spend 500 ms and every other user feels it.
For real CPU work, worker_threads gives you separate threads that run JavaScript in parallel. It has been in Node.js since v12 and there is nothing experimental about it now. I wrote about it in Node.js CPU intensive operations. Workers are for computation. They will not make your I/O faster, because your I/O was never the slow part.
And if file reads or outgoing requests go mysteriously slow under load, check whether something is holding the pool. UV_THREADPOOL_SIZE goes up to 1024, but a bigger pool usually just hides the call that should not have been there.
Further reading
Node.js event loop architecture by Andranik Keshishyan is the clearest explanation I have found of the thread-per-connection problem and why epoll had to exist. It is from 2019, so treat two things in it as historical. worker_threads is no longer new, and its link to the Node.js guide points at a path that now returns 404. The current guide is here.
Inside Node.js: Exploring Asynchronous I/O by ocodista is where the first two experiments come from, and it is very good on file descriptors and the move from select to epoll. It was written in December 2023, and its closing line says Node.js uses io_uring from version 20.3.0. That was true when published and is not true now, for the reasons in the callout above.
The official Don't block the event loop guide is the shortest useful thing Node.js publishes, and the libuv thread pool docs are worth five minutes if you want the official list.