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:

  1. Construction is synchronous but startup is not. new Worker(...) returns immediately with a live Worker object, but the actual OS thread and its V8 isolate are still booting in the background.
  2. The worker’s module graph loads independently. The worker re-runs import/require for everything it needs; nothing from the main thread’s already-loaded modules is reused or shared.
  3. workerData is 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.
  4. The online event fires once the thread is actually executing JavaScript, which is your first confirmation the worker is alive.
  5. Every subsequent exchange goes through postMessage, structured-cloning the value and delivering it as a message event on the other side’s own event loop — asynchronously, and independently of whatever the sending thread does next.
  6. The worker exits on its own once its event loop has nothing left to do (no pending timers, open handles, or an open parentPort being listened on), triggering the exit event; alternatively, you can force it with worker.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 error listener. Worker extends EventEmitter, so an unhandled error event 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 Worker per task — thread startup isn’t free.
  • Always attach both error and exit listeners 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 transferList option to transfer large ArrayBuffers instead of letting them be cloned, when the sender no longer needs them.
  • Reach for SharedArrayBuffer and Atomics only 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() (or os.cpus().length) rather than an arbitrary large number.
  • For production pooling, consider a well-established library like piscina rather than hand-rolling a queue and round-robin dispatcher from scratch.

Practice Exercises

  • Write a worker that receives an array of numbers through workerData and posts back the sum of their squares. In the main script, start a setInterval heartbeat 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-backed Int32Array and spawn two workers that each call Atomics.add(counter, 0, 1) 100,000 times. Confirm the final value is exactly 200000. Then replace Atomics.add with a plain counter[0]++ and observe how the final value becomes unpredictable due to the race condition.

Summary

  • node:worker_threads gives 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 + Atomics for true shared memory, and transferList to move large buffers without copying.
  • workerData delivers initial input at startup; postMessage/parentPort.postMessage and the message event handle ongoing exchange.
  • Always listen for error and exit, and call terminate() 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.