Worker Threads in Node.js

Last Updated: Sep 2022

More of my time has been examining ways to get better performance from my existing code or hardware. The more requests per second my API serves, the less cpu time for each request, the lower my cloud bill will be. Plus, I LIKE TO GO FAST! While exploring ways to do that, I came across the idea of threads. I started as a web dev and missed many computer science-type fundamentals when starting. I’m willing to be many of you have as well.

You know other languages have these things called “threads” and they can help reduce execution time but have probably never used them. Let take at threads in general and then look at how worker threads in Node can help us write better programs.

What Are Threads

In computer science, a thread of execution is the smallest sequence of programmed instructions that can be managed independently by a scheduler, which is typically a part of the operating system. The multiple threads of a given process may be executed concurrently (via multithreading capabilities), sharing resources such as memory, while different processes do not share these resources - Wikipedia

The last line here also helps us understand the differences between the previous way of doing things via cluster and worker threads. Cluster creates a whole different process with its memory, runtime, and associated overhead while worker threads skip the overhead and share memory via transferring an ArrayBuffer or sharing SharedArrayBufferinstances.

What are Worker Threads, and Why Do They Matter?

The problem is that by default the Javascript code you write can only be executed on a single thread. Because of this, we need a way to not tie up the main thread when we need to do CPU-intensive work and keep responding to requests. Interacting with the file system or making network requests uses threads behind the scenes in Node, so why can’t we? Worker Threads became stable in Node.js v12 and replace the old method of clustering. Worker threads also share memory where clusters cannot. With worker threads, we can spread out a given workload across the available CPU cores. Doing so allows those items to be processed in parallel and reduces the overall time our code takes to run. Let’s have a look at a basic example of worker threads.

Using Worker Threads

To follow along you will need to have Node.js installed on your computer. I am using the most recent Long Term Support build and managing my environment via NVM. Let us make a new file called index.js to start. With that done, we will need to import the worker_threads module to utilize it in our program. This will give us everything we need to get started.

const { parentPort, workerData } = require("node:worker_threads");

The Main Thread

The main thread acts like a supervisor, handing out work to the threads once they are done with their current task. In the code below we are making sure that we are in the main thread before spawning a worker thread. Otherwise, we could get into a situation where worker threads are infinitely instantiating themselves and crash the machine our code is running on.

We are also listening to the events of the worker. Here we listen for messages, errors, and exits. We can send data back from our workers by listening to a new message. The error block will catch any errors thrown by our workers and you can handle them here. The exit will indicate if the worker exited successfully or not.

if (isMainThread) {
  const worker = new Worker(__filename, {
    workerData: {},
  });
  worker.on("message", msg => console.log(`message: ${msg}`));
  worker.on("error", err => console.error(err));
  worker.on("exit", code => console.log(`exited with code ${code}.`));
} else {
  const heavy = require("./heavy");
  heavy.makeThingsSlow();
}

In the else block of the above if statement - meaning we are in fact in a worker thread and not on main, we import and call our bit of code that does the intensive operations. Let’s see what we have going on with our workers. Make a new file called heavy.js to hold this code.

The Worker

const { parentPort, workerData } = require("node:worker_threads");

function makeThingsSlow() {
  console.log(workerData);

  const LIMIT = 1e10;
  let val = 0;

  for (let i = 0; i < LIMIT; ++i) {
    ++val;
  }

  parentPort.postMessage(val);
}

module.exports = { makeThingsSlow };

This code simulates a CPU-bound operation that would cause your program to slow down. If the LIMIT variable looks a bit weird, no worries it is just shorthand notation. 1e10 represents a one with ten zeros behind it, or ten billion. So this for loop will iterate from zero to ten billion and pass the value val back to the parent thread.

Where to Use Worker Threads

I know it may not seem like much right now, but with this in your tool belt, you can get more out of the machines you deploy your code on! Worker threads fit into your Express APIs for those endpoints that have to crunch data and generate reports or any task that you find ties up the main thread for long periods.

It should be noted that worker threads are not a substitute for poorly performing code and do not replace fixing your slow code. When you have already made your code as fast as it can be and it still blocks the main thread, then you should reach for a worker.

Closing

Node.js worker threads provide a powerful tool for developers looking to improve the performance and scalability of their applications. With the execution of heavy computation tasks in separate threads, worker threads can help prevent the main event loop from being blocked, making your code more responsive and stable.

Worker threads also can share the memory with the main thread, enabling efficient data transfer and communication. Overall, worker threads are a valuable addition to the Node.js developer’s toolkit and can enhance the performance and scalability of any application.