Node.js Worker Threads Explained: True Parallelism
Node.js worker threads run JavaScript on real OS threads in parallel, letting CPU-heavy work run without blocking the event loop.
Worker threads are Node.js’s built-in mechanism for running JavaScript on a separate OS thread, in parallel with the main thread, each with its own event loop and its own isolated v8 instance. Node’s core strength — a single-threaded event loop handling thousands of concurrent I/O operations — falls apart the moment a piece of work is CPU-bound rather than I/O-bound. Worker threads exist to move that CPU-bound work off the main thread so it stops blocking everything else.
The problem: one thread, no cooperation
Node’s non-blocking I/O model works because operations like reading a file or querying a database hand control back to the event loop while they wait, letting other code run in the meantime. That model breaks down for synchronous, CPU-heavy work — parsing a large JSON payload, resizing an image, running a cryptographic hash, computing on a large in-memory dataset. Code like that runs to completion on the single JavaScript thread with nothing else able to run until it finishes, no matter how many other requests are waiting. A slow enough computation can stall an entire server’s request handling for the duration.
setImmediate, process.nextTick, and Promises don’t fix this — they all still execute on the same thread, just at different points in the event loop’s phases. None of them make CPU-bound code run in parallel; they only control when it runs relative to other queued work.
What a worker thread actually is
A Worker is a genuinely separate thread with its own V8 instance and its own event loop, created from the node:worker_threads module. It doesn’t share memory with the main thread by default — data passed between them is either copied (structured-cloned, the same algorithm behind structuredClone()) or, for specific typed-array-backed data, transferred, which hands over ownership rather than copying.
// main.js
const { Worker } = require("node:worker_threads");
const worker = new Worker("./heavy-task.js", {
workerData: { input: largeDataset },
});
worker.on("message", (result) => {
console.log("Computed:", result);
});
worker.on("error", (err) => console.error("Worker failed:", err));
// heavy-task.js
const { parentPort, workerData } = require("node:worker_threads");
const result = expensiveComputation(workerData.input);
parentPort.postMessage(result);
Because each worker has its own event loop, CPU-heavy code inside heavy-task.js runs genuinely in parallel with the main thread’s event loop — the main thread stays free to keep handling I/O and requests while the worker crunches through its computation.
Shared memory: SharedArrayBuffer and Atomics
The default message-passing model copies data on every postMessage() call, which is fine for small payloads but expensive for large ones. For cases where two threads genuinely need to read and write the same memory without copying, Node supports SharedArrayBuffer paired with Atomics — a shared block of memory both threads can access directly, with atomic operations to coordinate access and avoid the classic race conditions that come with genuinely concurrent memory access. This is a lower-level, more error-prone tool than message passing, and worth reaching for only when the copying overhead of postMessage is a measured bottleneck.
Worker threads vs cluster vs child_process
Node has three distinct ways to get work off the main thread, and they solve different problems:
| Worker threads | cluster module | child_process | |
|---|---|---|---|
| Isolation | Separate thread, same process | Separate process | Separate process |
| Memory sharing | Optional, via SharedArrayBuffer | None (separate memory) | None (separate memory) |
| Startup cost | Low | Higher (full process) | Higher (full process) |
| Typical use | CPU-bound JS computation | Scaling an HTTP server across CPU cores | Running external programs, or fully isolated Node processes |
| Crash isolation | A worker crash doesn’t kill the process | A crashed worker process can be respawned | Same as cluster |
cluster forks multiple full Node processes that share a listening port, which is the standard way to use every CPU core for a web server handling many independent requests. Worker threads are the better fit when a single logical task needs to offload heavy computation without spinning up a whole separate process — lighter weight to start, and able to share memory when that’s genuinely needed.
When worker threads are the wrong tool
Worker threads solve CPU-bound problems. They don’t speed up I/O-bound work at all — a worker thread making a database query or an HTTP request gains nothing over doing it on the main thread, since Node’s I/O was already non-blocking. Spinning up a worker for something that was already async and I/O-bound just adds thread creation overhead and message-passing latency for no benefit. Reach for worker threads specifically when profiling shows the main thread is busy computing, not waiting.
Worker creation also isn’t free — instantiating a new Worker and its V8 instance takes measurable time, which is why long-lived worker pools (reusing a fixed set of workers across many tasks, similar in spirit to how Node.js streams avoid re-reading data) are common in production rather than spinning up a fresh worker per request.
The takeaway
Worker threads give Node genuine parallelism for CPU-bound JavaScript, running on a separate thread with its own event loop rather than competing for time on the main one. Use them when profiling shows the main thread is busy computing, not waiting on I/O; prefer message passing for most cases and reach for SharedArrayBuffer/Atomics only when copying overhead is a proven bottleneck; and pick cluster instead when the goal is scaling a whole server across CPU cores rather than offloading one task.
Tagged
Keep reading
Takina · · 4 min read Node.js Cluster vs Worker Threads: Which to Use
Node's cluster module forks whole processes to use every CPU core; worker_threads share memory inside one process. How to pick between them.
Takina · · 6 min read How to Break Up Long Tasks in JavaScript
Long tasks block the main thread for 50ms or more and make pages feel frozen. How to split them with yielding, scheduler.yield(), postTask, and workers.
Takina · · 5 min read Passive Event Listeners: Smoother Scrolling in JavaScript
Passive event listeners tell the browser a handler won't call preventDefault(), so scrolling starts at once. How { passive: true } works and when to use it.