Skip to content

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

Aman Kumar Singh5 min read
Part 4 of 7From the Node.js Runtime Internals series
Node.js Worker Threads: Moving CPU Work Off the Main Thread — article by Aman Kumar Singh

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)
})

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)
})

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.

Key takeaways

  • CPU-bound work holds the single JavaScript thread and blocks every other request; worker threads run that work on separate threads in parallel.
  • Each worker has isolated memory and communicates by message passing, which copies data by default — a real cost for large payloads.
  • Use a worker pool (fixed set of reused workers) rather than spawning one per task, since each worker starts a fresh V8 instance.
  • Worker threads are for CPU-bound work only; wrapping I/O-bound work in a worker adds complexity for no gain, since the event loop already handles I/O.
  • The test: is the task computing or waiting? Computing is a worker candidate; waiting is I/O and belongs on the main thread.

Frequently asked questions

What are worker threads used for in Node.js?

Running CPU-bound JavaScript off the main thread so it does not block the event loop. Tasks like image resizing, parsing huge documents, hashing, or heavy data transformation run on a separate thread in parallel, keeping the main thread free to serve other requests.

When should I NOT use worker threads?

For I/O-bound work — waiting on a database, an API, or the disk. Node already handles I/O concurrently on the main thread via the event loop, so wrapping a query in a worker adds thread and message-passing overhead to solve a problem that does not exist. Use workers only when the task is computing, not waiting.

Why use a worker pool instead of a worker per task?

Because spawning a worker starts a fresh V8 instance, which costs time and memory. Creating one per request trades a CPU problem for a startup-overhead problem. A pool of reused workers amortizes the startup cost and bounds how many threads run, avoiding oversubscribing the cores.

How do worker threads communicate with the main thread?

Through message passing — workerData and postMessage in, postMessage out — because each worker has isolated memory. Data is copied by default, which has a cost for large payloads. ArrayBuffers can be transferred instead of copied, and SharedArrayBuffer allows direct shared memory for high-throughput cases.

Related articles

Node.js Memory Leaks and Profiling: Finding What Holds Memory — Aman Kumar Singh
Node.js Streams and Backpressure: Processing Data Without Running Out of Memory — Aman Kumar Singh
Node.js Error Handling: Operational vs Programmer Errors — Aman Kumar Singh

Explore more on

Aman Kumar Singh

About the author

I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.