Understanding Node.js Threads

Node.js is full of surprises, especially when it comes to threads. The first thing you might hear about it is, “There is only one thread.” After digging a bit deeper, we can discover that Node.js is, at the very least, not single-threaded.

But what are all those threads? Do you know the difference between them? At first, it might be counterintuitive, but different threads are meant for different purposes. Writing and reasoning about the code you encounter becomes hard without properly understanding those differences.

In this article, we will build a mental model you can rely on while working with Node.js threads. The article assumes you already have some basic knowledge of how Node.js operates and its high-level architecture.

To better understand threads, we must understand what resource-related operations we can perform in Node.js.

By resources-related operations, I mean operations that, for the most part, depend on one of the following hardware resources:

  • Input and output or I/O

  • CPU

  • Different kinds of memory

The list is not limited to these three, and it can go on and on. However, we’re primarily interested in the first two: I/O and CPU.

Every operation in a system uses some resources. For example, a function can read a file's content and then do heavy calculations based on it. First, we use the I/O resource to read the file and then the CPU to perform calculations.

Some operations rely heavily on one kind of resource. These operations are called “*-bound,” where “*” means any type of resource. If the operation mostly relies on the CPU, and the better the CPU gets, the faster the operation completes, we can call it CPU-bound. The same can be applied to I/O-bound operations.

I/O-bound operations

I/O-bound operations are the ones that involve input and output of any kind in a system.

For example, imagine we have some Excel file that we want to read and do something with it afterward. To achieve that, we can use file system API provided by Node.js. The following code does the job:

import { readFile } from 'node:fs/promises';

const userExcelDoc = await readFile('./todo-list.excel');

Notice that we don’t do any heavy lifting here. The platform APIs nicely abstract everything from us. Our job is to call the function and pass a valid file path — that’s it.

But what if we have 2 or 3 large files that we want to read at the same time? Are they going to block the main thread? Not really. We have a great mechanism for handling I/O operations in Node.js that we’ll look at in a minute.

CPU-bound operations

CPU-bound operations are the ones that involve any logic with a high demand for CPU resources.

To demonstrate CPU-bound operation, we don’t need much, just a simple cycle that runs billions of times:

for (let = 0; i < 20000000000; i++) {
  // do something
}

Here we have it, the operation that requires a lot of CPU resources. If we run this code in our main thread, the whole server will be frozen until it's done.

Unlike I/O-bound operations, we don’t have a low-level platform API that does the heavy lifting for us. Nobody will allocate a separate thread for it automatically, so we have to find another way to do so by ourselves.

Thread types in Node.js

Now, we’re ready look at different types of threads in Node.js, here we go!

Libuv threads

Libuv is the library that provides an event loop, which makes it possible to perform asynchronous operations on a server. At the same time, it enables us to interact with different OS by providing a high-level abstraction.

This library has 3 main parts: thread pool, event loop, and callback queue.

The thread pool has 4 threads allocated by default, and the size can be increased up to 1028 threads. The thread pool is used to run three types of operations:

  • File system operations

  • DNS functions (getaddrinfo and getnameinfo)

  • User-defined code (when using libuv API directly)

One more thread comes from the event loop itself. The library strictly allocates only a single thread to run the event loop.

We can see that only the libuv library alone can create up to 1029 (1028 thread pool + 1 event loop) threads if needed, and we’re not even counting the one that it might use internally.

When talking about I/O operations in Node.js, the libuv library is the exact component that handles them.

JavaScript engine

The default JS engine shipped with Node.js is V8, but it is a pluggable part that can be switched to JavaScriptCore, SpiderMonkey, or any other.

Despite specific JS engine implementation, it has its own threads. Here is a list of operations that those threads can be allocated for:

  • Garbage collection

  • Compilation of JavaScript

  • System tasks like monitoring and others.

Those are a few examples. The main idea is that the engine can freely create new threads whenever it needs them to function properly and is not limited to a specific number.

Addons and Node-API

The addons and Node-API are basically a bridge that allows you to write custom C++ and C code and plug it into your Node.js application.

You might be using addons or Node-API yourself. If you’re not using them directly, some of the dependencies that you’re using might do so.

It looks similar to the V8 engine case, where we don’t know the exact number of threads for an addon to function properly. Each addon can spawn multiple threads and use multiple resources whenever it needs to.

Worker threads

Last but not least is the worker threads module. Its unique feature is the ability to create new threads directly from JavaScript code. No other components or APIs of Node.js give such power.

Here is an example of how to create a thread using the module:

const { Worker } = require('node:worker_threads');

const worker = new Worker('./worker.js');

The Worker class is a synonym for an independent thread in this case. Because of this, it is common to hear that threads created by Worker class are referred to as “workers” or "worker threads" instead of just threads.

The worker can encapsulate CPU-bound operations in JavaScript without blocking the main thread. As an example, we can refer to the billion iterations loop example that we mentioned before.

Conclusion

While working with Node.js, we'll encounter two primary resource-related tasks: CPU-bound and I/O-bound operations.

Node.js, as a platform, uses many different types of threads to make it work properly all together. Separate components of the platform create as many threads as they need to function properly.

In essence, all of those threads are the same from the perspective of an operating system. However, they have different roles and responsibilities regarding the platform as a whole.