Worker Threads
Node.js runs your JavaScript on a single thread by default, which is why the event loop model exists: instead of blocking on I/O, Node hands work off to the operating system or its own libuv thread pool and keeps that one thread free. But not everything can be offloaded that way. Heavy CPU work — image resizing, PDF generation, cryptographic hashing, parsing a huge file — runs as plain synchronous JavaScript, and while that code executes nothing else in the process can run. The node:worker_threads module solves this by giving you real additional threads, each with its own V8 instance and its own event loop, so CPU-bound work can run in parallel with the rest of your application instead of freezing it.
Overview: How Worker Threads Work
Node’s concurrency model has two very different layers, and it’s easy to conflate them. The first layer is libuv’s internal thread pool, which Node already uses under the hood for things like fs file operations, DNS lookups, and some crypto functions. That pool runs C++ code, not your JavaScript, and it’s invisible to you — you just get a callback or a resolved promise when the work is done. The second layer is your actual JavaScript, which always runs on a single thread per V8 isolate. A worker thread is a way to get a second (or third, or tenth) isolate, each running its own copy of the event loop, its own microtask queue, and its own module graph. When you create a Worker, Node spins up a real OS thread, boots a fresh V8 context inside it, and loads the target script into that context as if it were a small, separate Node program.
Because each worker has its own isolate, workers do not share memory or variables the way threads do in languages like Java or C#. Data passed between the main thread and a worker — via workerData or postMessage() — is copied using the structured clone algorithm, the same mechanism the browser uses for postMessage. You are exchanging serialized copies, not references. If you need actual shared memory, Node provides SharedArrayBuffer combined with the Atomics API: multiple threads can read and write the same underlying block of memory, but you are responsible for coordinating access yourself to avoid race conditions. For large binary buffers you don’t want to copy, you can also transfer ownership of an ArrayBuffer instead of cloning it, using the transferList option — the sending thread loses access and the receiving thread gets it for free, with no copy cost.
Worker threads aren’t the only way to get concurrency in Node. It’s worth knowing how the three options compare:
| Feature | worker_threads |
child_process |
cluster |
|---|---|---|---|
| Isolation | Same process, separate V8 isolate | Separate OS process | Separate OS process (built on child_process) |
| Memory | Can share memory via SharedArrayBuffer |
No shared memory | No shared memory |
| Startup cost | Low (a few ms) | Higher (new process) | Higher (new process) |
| Best for | CPU-bound JS computation | Running other programs, or isolating untrusted/crash-prone code | Scaling an HTTP server across CPU cores |
Syntax
A worker is created with new Worker(filename, options). The filename can be a path or, more robustly, a file URL built with import.meta.url. Inside the worker file, the same module exports different values depending on whether it’s the main thread or the worker itself:
import { Worker, isMainThread, parentPort, workerData, threadId } from "node:worker_threads";
if (isMainThread) {
// Runs when this file is loaded as the main program
const worker = new Worker(new URL(import.meta.url), {
workerData: { key: "value" },
});
worker.on("message", (msg) => console.log("From worker:", msg));
} else {
// Runs when this same file is loaded as a worker
console.log(`Worker ${threadId} received`, workerData);
parentPort.postMessage("done");
}
Key pieces of the API:
| Member | Where | Purpose |
|---|---|---|
isMainThread |
either | true in the original thread, false inside a worker |
threadId |
either | numeric id of the current thread (0 is the main thread) |
workerData |
worker only | a clone of the workerData option passed to the constructor |
parentPort |
worker only | a MessagePort used to send/receive messages with the creator |
worker.postMessage(value, transferList?) |
main thread | send a message into the worker |
worker.terminate() |
main thread | forcibly stop the worker and its thread |
A Worker instance emits events you should always listen for:
| Event | Fires when |
|---|---|
online |
the worker thread has started executing JavaScript |
message |
the worker called parentPort.postMessage() |
error |
an uncaught exception occurred inside the worker |
exit |
the worker stopped, with the exit code as its argument |
Examples
Example 1: A basic worker with input and output
This example assumes a project with \"type\": \"module\" in package.json, so plain .js files — including worker scripts — are loaded as ES modules. worker.js reads its input from workerData and reports a result with parentPort.postMessage():
import { parentPort, workerData } from "node:worker_threads";
const sum = workerData.a + workerData.b;
parentPort.postMessage(sum);
main.js creates the worker, passes it data, and listens for the result:
import { Worker } from "node:worker_threads";
const worker = new Worker(new URL("./worker.js", import.meta.url), {
workerData: { a: 5, b: 7 },
});
worker.on("message", (result) => {
console.log("Result from worker:", result);
});
worker.on("error", (err) => {
console.error("Worker error:", err.message);
});
worker.on("exit", (code) => {
if (code !== 0) {
console.error(`Worker stopped with exit code ${code}`);
}
});
Result from worker: 12
The worker receives its input synchronously through workerData at startup, does a tiny bit of work, and reports back through the message channel. Because the worker exits after its script finishes running (there’s nothing left keeping its event loop alive), the exit event fires automatically once the message has been sent.
Example 2: Why this matters — a blocking computation
To see the actual problem worker threads solve, look at what happens when a slow computation runs directly on the main thread:
function fibonacci(n) {
if (n < 2) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
console.log("Start");
const result = fibonacci(40); // synchronous — blocks the event loop
console.log("Fibonacci(40) =", result);
console.log("Nothing else in this process could run while that line executed.");
Start
(a multi-second pause — no timers, no I/O callbacks, no other requests can run)
Fibonacci(40) = 102334155
Nothing else in this process could run while that line executed.
The naive recursive Fibonacci is deliberately expensive. On the main thread, that call to fibonacci(40) occupies the only thread your whole application has. In a server process, every incoming request, every timer, every pending I/O callback queues up and waits until this one synchronous call returns.
Example 3: Moving the work to a worker thread
cpu-worker.js does the same computation, but now it happens on its own thread:
import { parentPort, workerData } from "node:worker_threads";
function fibonacci(n) {
if (n < 2) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
parentPort.postMessage(fibonacci(workerData.n));
main-cpu.js dispatches the work and proves the main thread stays responsive while it runs, by logging a heartbeat on a timer:
import { Worker } from "node:worker_threads";
function runFibonacciWorker(n) {
return new Promise((resolve, reject) => {
const worker = new Worker(new URL("./cpu-worker.js", import.meta.url), {
workerData: { n },
});
worker.on("message", resolve);
worker.on("error", reject);
worker.on("exit", (code) => {
if (code !== 0) reject(new Error(`Worker stopped with exit code ${code}`));
});
});
}
const heartbeat = setInterval(() => {
console.log("Main thread is still responsive...");
}, 200);
const result = await runFibonacciWorker(40);
console.log("Fibonacci(40) =", result);
clearInterval(heartbeat);
Main thread is still responsive...
Main thread is still responsive...
Main thread is still responsive...
(prints roughly every 200ms while the worker computes in the background)
Fibonacci(40) = 102334155
The heartbeat keeps firing on its own schedule the entire time the Fibonacci call is running, because that call now happens on a completely separate thread with its own event loop. The main thread’s event loop was never blocked — it just waited on a promise, which is cheap, instead of running a multi-second synchronous computation.
Under the Hood: What Happens When You Create a Worker
Understanding the order of operations helps you reason about timing bugs:
- Construction is synchronous but startup is not.
new Worker(...)returns immediately with a liveWorkerobject, but the actual OS thread and its V8 isolate are still booting in the background. - The worker’s module graph loads independently. The worker re-runs
import/requirefor everything it needs; nothing from the main thread’s already-loaded modules is reused or shared. workerDatais cloned once, at startup, and handed to the worker before its top-level code runs. It is not a message you listen for — it’s simply available as soon as the module body executes.- The
onlineevent fires once the thread is actually executing JavaScript, which is your first confirmation the worker is alive. - Every subsequent exchange goes through
postMessage, structured-cloning the value and delivering it as amessageevent on the other side’s own event loop — asynchronously, and independently of whatever the sending thread does next. - The worker exits on its own once its event loop has nothing left to do (no pending timers, open handles, or an open
parentPortbeing listened on), triggering theexitevent; alternatively, you can force it withworker.terminate().
Common Mistakes
Mistake 1: Assuming data passed to a worker is a live reference
Because workerData and message payloads are cloned, mutating them inside the worker never changes anything in the main thread:
// WRONG: workerData is a clone, not a live reference to the object in the main thread
import { workerData } from "node:worker_threads";
workerData.counter = workerData.counter + 1;
console.log(workerData.counter); // this change is invisible to the main thread
To get an updated value back to the main thread, send it explicitly through postMessage:
// worker.js — send the updated value back instead of mutating workerData
import { parentPort, workerData } from "node:worker_threads";
const updatedCounter = workerData.counter + 1;
parentPort.postMessage({ counter: updatedCounter });
If you genuinely need shared, mutable state across threads (a counter both sides increment concurrently, for example), use a SharedArrayBuffer with Atomics.add()/Atomics.load() instead — that’s the one case where changes really are visible on both sides immediately, because it’s the same block of memory rather than a copy.
Mistake 2: Spawning a new worker per request and never cleaning it up
Worker threads have real startup and memory cost (a fresh V8 isolate isn’t free). Creating one per HTTP request without ever calling terminate() leaks a thread every time:
// WRONG: creates a brand-new worker (and a new thread) for every single request,
// and never terminates it or handles a failure
import { Worker } from "node:worker_threads";
import http from "node:http";
http.createServer((req, res) => {
const worker = new Worker(new URL("./worker.js", import.meta.url));
worker.on("message", (result) => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(result));
});
}).listen(3000);
At minimum, terminate the worker once it’s done and handle the case where it errors instead of hanging the response forever:
import { Worker } from "node:worker_threads";
import http from "node:http";
http
.createServer((req, res) => {
const worker = new Worker(new URL("./worker.js", import.meta.url));
worker.on("message", (result) => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(result));
worker.terminate();
});
worker.on("error", (err) => {
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: err.message }));
worker.terminate();
});
})
.listen(3000);
In real production code you’d go one step further and keep a small pool of long-lived workers that receive tasks via postMessage instead of spinning up (and tearing down) a thread on every request — thread creation is much more expensive than message passing.
Other mistakes worth knowing
- No
errorlistener.WorkerextendsEventEmitter, so an unhandlederrorevent can crash the main thread. Always attach one. - Using worker threads for I/O-bound work. Reading a file, querying a database, or calling an API is already asynchronous and non-blocking in Node — wrapping it in a worker just adds thread overhead for no benefit. Worker threads are for CPU-bound synchronous computation.
Best Practices
- Reserve worker threads for genuinely CPU-bound work (parsing, hashing, image/data processing); let Node’s normal async I/O handle everything else.
- Reuse a pool of long-lived workers for many small tasks instead of creating a new
Workerper task — thread startup isn’t free. - Always attach both
errorandexitlisteners on every worker you create. - Call
worker.terminate()once a worker’s job is done if you’re not reusing it, so you don’t leak threads. - Use the
transferListoption to transfer largeArrayBuffers instead of letting them be cloned, when the sender no longer needs them. - Reach for
SharedArrayBufferandAtomicsonly when you truly need shared mutable state, and keep that surface area as small as possible — you own the synchronization. - Size a worker pool around
os.availableParallelism()(oros.cpus().length) rather than an arbitrary large number. - For production pooling, consider a well-established library like
piscinarather than hand-rolling a queue and round-robin dispatcher from scratch.
Practice Exercises
- Write a worker that receives an array of numbers through
workerDataand posts back the sum of their squares. In the main script, start asetIntervalheartbeat before creating the worker to confirm the main thread keeps running while the worker computes. - Build a tiny pool of 4 long-lived workers that each listen for tasks via
parentPort.on("message", ...), process them, and post results back. Dispatch 20 tasks round-robin across the pool from the main thread and collect all 20 results before exiting. - Create a
SharedArrayBuffer-backedInt32Arrayand spawn two workers that each callAtomics.add(counter, 0, 1)100,000 times. Confirm the final value is exactly 200000. Then replaceAtomics.addwith a plaincounter[0]++and observe how the final value becomes unpredictable due to the race condition.
Summary
node:worker_threadsgives Node real parallel JavaScript execution, each worker with its own V8 isolate and event loop — unlike libuv’s internal thread pool, which only offloads system-level I/O, not your JS.- Data between threads is copied via structured clone by default; use
SharedArrayBuffer+Atomicsfor true shared memory, andtransferListto move large buffers without copying. workerDatadelivers initial input at startup;postMessage/parentPort.postMessageand themessageevent handle ongoing exchange.- Always listen for
errorandexit, and callterminate()when a worker is no longer needed. - Worker threads are for CPU-bound computation, not I/O — I/O is already non-blocking in Node without them.
- Reuse workers in a pool for repeated small tasks; spawning a fresh thread per task wastes the startup cost that pooling avoids.
