Safeguard
DevSecOps

Worker Threads in Node.js: How They Work and How to Use Them Safely

A worker thread in Node.js runs JavaScript in parallel on a separate thread, letting you offload CPU-heavy work without blocking the event loop. Here is how they work and the security pitfalls to avoid.

Marcus Chen
DevSecOps Engineer
6 min read

A worker thread in Node.js is a real operating-system thread that runs JavaScript in parallel with the main thread, letting you move CPU-intensive work off the event loop so it stays responsive. Node.js worker threads live in the built-in worker_threads module, which became stable in Node.js 12, and they exist to solve one specific problem: Node is single-threaded by default, so a long synchronous computation blocks everything, and worker threads are the sanctioned escape hatch.

If you have ever wondered "what is a thread in Node.js, given that Node is supposed to be single-threaded" — this is the answer. The event loop handles I/O concurrency beautifully, but it does not help with CPU-bound work, and that gap is exactly what worker threads fill.

Why Node needed threads at all

Node's core model is a single-threaded event loop. That works because most server work is I/O — reading files, querying databases, calling APIs — and I/O is asynchronous: the event loop hands off the wait and moves on. For that workload, one thread is plenty and avoids a whole category of locking bugs.

The model breaks down the moment you have genuine CPU work: parsing a huge document, resizing images, running a cryptographic hash over a large payload, compressing data. That work runs synchronously on the one thread, and while it runs, every other request waits. Your throughput collapses and latency spikes across the board.

Worker threads let you spin up additional JavaScript execution contexts, each with its own event loop and V8 instance, so that heavy computation happens elsewhere and the main thread keeps serving requests.

How a worker thread works

Each worker runs a separate JavaScript file. The main thread creates one and communicates with it by passing messages:

// main.js
const { Worker } = require("node:worker_threads");

function runHeavyTask(input) {
  return new Promise((resolve, reject) => {
    const worker = new Worker("./worker.js", { workerData: input });
    worker.on("message", resolve);
    worker.on("error", reject);
    worker.on("exit", (code) => {
      if (code !== 0) reject(new Error(`Worker stopped with code ${code}`));
    });
  });
}
// worker.js
const { parentPort, workerData } = require("node:worker_threads");

// CPU-heavy work runs here, off the main event loop.
const result = expensiveComputation(workerData);
parentPort.postMessage(result);

The key difference from browser Web Workers or from OS threads in other languages is what gets shared. By default nothing is shared: workerData and messages are copied using the structured-clone algorithm, not shared by reference. That copy semantics is what keeps worker threads relatively safe — two threads mutating the same object is the source of most concurrency bugs, and Node sidesteps it by default.

When to reach for a worker (and when not to)

Worker threads are the right tool for CPU-bound work. They are the wrong tool for I/O-bound work, where the event loop already gives you concurrency for free. A common mistake is wrapping a database call in a worker thread — that adds overhead and the serialization cost of copying data across the thread boundary while solving nothing, because the database call was never blocking the CPU in the first place.

Creating a worker is not free: it spins up a new V8 isolate, which costs memory and startup time. For repeated tasks, do not create a worker per request — use a pool. Libraries like piscina manage a fixed pool of workers and hand tasks to whichever is idle, which amortizes the startup cost.

A rough decision rule:

  • Long CPU computation blocking the loop, use a worker (pooled).
  • Waiting on I/O, use async/await on the main thread; no worker needed.
  • Fully separate program that could crash independently, consider a child process instead.

The security angles people miss

Worker threads change your threat model in ways that are easy to overlook.

Do not run untrusted code in a worker and assume isolation. A worker thread is not a sandbox. It shares the same process, the same memory space at the OS level, and the same privileges as the main thread. If you run attacker-supplied code in a worker thinking it is contained, it is not — a worker can access the file system and network exactly as the parent can. For untrusted code you need real isolation: a separate process with dropped privileges, a container, or a genuine sandbox like a WASM runtime.

SharedArrayBuffer reintroduces race conditions. The default copy semantics keep you safe, but SharedArrayBuffer deliberately shares memory between threads for performance. The moment you use it, you are back in classic concurrency territory: data races, torn reads, and the need for Atomics to coordinate. Use it only when you have measured that copying is your bottleneck, and treat the shared region as carefully as you would in C.

Resource exhaustion. Uncapped worker creation is a denial-of-service vector against yourself. If each incoming request can spawn a worker, an attacker sending a burst of requests can exhaust your memory and CPU. A bounded pool is a security control, not just a performance one.

Dependency risk crosses the boundary. Code in a worker imports its own node_modules and carries the same supply-chain exposure as your main code. A vulnerable transitive dependency is just as exploitable inside a worker; an SCA scan of your project covers worker code too, since it analyzes the whole dependency tree.

A practical pattern

For a production service, the mature setup is: identify the specific CPU-bound operations, move each into a worker file, put a bounded pool in front, validate and size-limit the input before it crosses the boundary (because you are about to copy it), and never route untrusted-code execution through a worker expecting isolation. Done that way, worker threads give you real parallelism on the CPU-bound paths without inheriting the classic thread-safety nightmares, because Node's copy-by-default design did most of the hard work for you.

FAQ

What is a thread in Node.js if Node is single-threaded?

Node's event loop runs your JavaScript on a single main thread, which handles I/O concurrency asynchronously. A worker thread is an additional real thread you create with the worker_threads module to run JavaScript in parallel, specifically for CPU-bound work that would otherwise block the main thread.

When should I use Node.js worker threads?

Use them for CPU-intensive work — heavy parsing, image processing, compression, cryptographic hashing over large data — that would block the event loop. Do not use them for I/O-bound work, where async/await on the main thread already provides concurrency without the overhead.

Are worker threads a security sandbox?

No. A worker thread shares the process, privileges, file-system access, and network access of the main thread. Never run untrusted code in a worker expecting isolation; use a separate hardened process, container, or WASM sandbox for that.

How do I avoid the overhead of creating workers?

Do not spawn a worker per request. Use a bounded worker pool (for example via a library like piscina) that reuses a fixed set of workers, which amortizes the V8 startup cost and doubles as a safeguard against resource-exhaustion attacks.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.