Offloading CPU work to worker threads

Node.js · Practical · 13 min

A single slow computation in the main thread freezes every request your server is handling.

Node runs your JavaScript on one thread. That's perfect for I/O, but a CPU-bound task — hashing, image resizing, parsing a huge JSON file, a tight numeric loop — blocks the event loop: no other requests are served, no timers fire, until it finishes.

`node:worker_threads` runs JavaScript on additional threads. Each Worker has its own V8 isolate, event loop and module graph; workers don't share variables with the main thread. They communicate by messages:

  • Create one with new Worker(new URL("./worker.js", import.meta.url), { workerData }).
  • In the worker, workerData holds the initial input, and parentPort.postMessage(result) sends results back.
  • The parent listens for "message", "error" (an uncaught exception in the worker) and "exit".

Messages are copied with the structured clone algorithm — plain objects, arrays, Maps, Dates, typed arrays work; functions and class instances' prototypes don't survive. Large binary data can be transferred instead of copied by listing an ArrayBuffer in the transfer list.

Starting a worker costs a few milliseconds and some megabytes, so don't spawn one per request. For anything frequent, keep a pool of long-lived workers sized to the CPU count (os.availableParallelism()); libraries like piscina do this well.

Workers are the wrong tool for I/O: the main thread already does I/O concurrently. Use them only when profiling shows JavaScript computation is the bottleneck. Native operations such as crypto.pbkdf2, zlib and fs already run on libuv's thread pool when you use their async versions — prefer those over hand-rolled workers.

Example

// fib-worker.js — one file acting as both main thread and worker
import { Worker, isMainThread, parentPort, workerData } from "node:worker_threads";

function fib(n) { return n < 2 ? n : fib(n - 1) + fib(n - 2); }   // deliberately slow

if (isMainThread) {
  const runFib = (n) => new Promise((resolve, reject) => {
    const worker = new Worker(new URL(import.meta.url), { workerData: n });
    worker.once("message", resolve);
    worker.once("error", reject);
  });

  const ticker = setInterval(() => console.log("main thread still responsive"), 200);
  console.log("fib(40) =", await runFib(40));   // computed on another thread
  clearInterval(ticker);
} else {
  parentPort.postMessage(fib(workerData));
}

Remember

  • CPU-bound JS blocks the event loop; workers run it on other threads
  • Workers share nothing by default and talk via structured-clone messages
  • Reuse workers through a pool; never spawn one per request

Watch out for

  • Using worker threads for I/O-bound work, adding overhead with no benefit.

Check yourself

  1. Which task benefits most from a worker thread?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: nodejs.org/docs/latest-v22.x/api/worker_threads.html