DEV Community

Aman Kumar Singh
Aman Kumar Singh

Posted on • Originally published at amanksingh.com

Node.js Worker Threads: Moving CPU Work Off the Main Thread

Node.js is superb at I/O and helpless at CPU-bound work on the main thread — a direct consequence of the single-threaded event loop. The moment your code has to do something genuinely computational — resize an image, parse a huge document, hash passwords, run a data transformation — that work holds the one JavaScript thread and every other request waits behind it. Worker threads are Node's answer: a way to run JavaScript on separate threads so heavy computation happens off the main thread, leaving the event loop free to keep serving requests.

This is part of the Node.js runtime series. Here we cover what worker threads are for, when to use them, and — just as important — when not to.

The problem: CPU work blocks everything

Recall the core constraint. Node runs your JavaScript on a single thread, and that thread's availability is what lets Node juggle thousands of connections. A CPU-heavy function does not yield — it runs straight through, holding the thread the entire time:

app.post("/resize", (req, res) => {
  const output = resizeImageSynchronously(req.body) // 800ms of pure CPU
  res.send(output)
})
Enter fullscreen mode Exit fullscreen mode

For those 800 milliseconds, the event loop cannot advance. No other request is served, no queued I/O callback runs, health checks time out. One CPU-bound endpoint can make an entire otherwise-fast service unresponsive under load. Making the function faster only shrinks the problem; it does not remove it. The real fix is to run that computation somewhere other than the main thread.

What worker threads are

The worker_threads module lets you spawn additional threads that each run JavaScript in their own isolated environment — their own V8 instance, their own event loop, their own memory. You hand a worker a task, it runs on a separate thread, and it sends the result back when done. Because it is a different thread, the heavy computation runs in parallel with the main thread, which stays free to keep the event loop turning.

const { Worker } = require("node:worker_threads")

function runTask(data) {
  return new Promise((resolve, reject) => {
    const worker = new Worker("./resize-worker.js", { workerData: data })
    worker.on("message", resolve)
    worker.on("error", reject)
  })
}

app.post("/resize", async (req, res) => {
  const output = await runTask(req.body) // main thread stays free
  res.send(output)
})
Enter fullscreen mode Exit fullscreen mode

Now the main thread offloads the work and awaits the result like any other async operation, so the event loop keeps serving other requests while the worker computes. The CPU work still takes 800 ms, but it no longer freezes the process.

Communication and its cost

Worker threads do not share ordinary variables with the main thread — each has isolated memory — so they communicate by passing messages. You send data in with workerData or postMessage, and the worker sends results back with its own postMessage. This message passing is the model to keep in mind, because it has a cost: data sent between threads is, by default, copied. For large payloads that copy is not free, and if you are shuttling big buffers back and forth constantly, the copying overhead can eat into the benefit of parallelizing.

Node offers ways to reduce this. ArrayBuffers can be transferred rather than copied — ownership moves to the worker and the sender loses access, which is instant regardless of size — and SharedArrayBuffer lets threads share a block of memory directly. These are worth knowing for high-throughput cases, but for most uses the plain copy is fine; just be aware that the boundary between threads is a real cost, not a free function call.

Use a pool, not a worker per task

Spawning a worker is not free — each one starts a fresh V8 instance, which takes time and memory. Creating a new worker for every request would trade your CPU problem for a startup-overhead problem. The standard pattern is a worker pool: create a fixed set of workers up front and hand incoming tasks to whichever one is free, reusing them across many tasks. This amortizes the startup cost and bounds how many threads you run, since spawning unlimited workers would oversubscribe your cores and slow everything down. Libraries like Piscina implement a solid pool so you do not have to build the queuing and lifecycle management yourself.

When NOT to use worker threads

This is the part people get wrong, so it deserves emphasis: worker threads are for CPU-bound work, not I/O-bound work. If your task is waiting on a database, an API, or the disk, worker threads are the wrong tool, and reaching for them will make your code more complex for no gain. Node already handles I/O concurrently on the main thread through the event loop — that is its whole strength. Wrapping a database query in a worker adds thread overhead and message-passing cost to solve a problem that did not exist, because the query was never blocking the thread in the first place.

The test is simple: is the task spending its time computing or waiting? If it is computing — crunching numbers, transforming data, compressing, hashing — that is CPU-bound and a candidate for a worker. If it is waiting on something external, it is I/O-bound, and it belongs on the main thread with ordinary async patterns. Getting this distinction right is most of using worker threads well.

Workers versus clustering versus a separate service

Worker threads are one of three ways to get past the single-thread limit, and they solve a specific slice of it. Use worker threads to move CPU-bound work off the main thread within one process. Use the cluster module to run multiple Node processes so you use all the machine's cores for handling requests, which is about throughput across the whole app rather than offloading one heavy task. And for very heavy or long-running computation, moving the work to a separate service behind a message queue is often cleaner than keeping it in the request process at all, because it isolates the heavy work entirely and lets you scale it independently.

Worker threads fit in the middle: heavier than an async call, lighter than a separate service, and exactly right when you have occasional CPU-bound tasks that would otherwise block the loop. Reach for them when the profiler shows the main thread pinned on computation — and leave them alone when it is just waiting on I/O.


Originally published at amanksingh.com.

Top comments (0)