Skip to content
Node.js nd workers 4 min read

Introduction to Worker Threads

Node.js runs your JavaScript on a single thread, which is perfect for I/O-heavy work but falls apart the moment you need to crunch numbers. A long synchronous computation blocks the event loop, freezing every other request, timer, and callback until it finishes. The node:worker_threads module solves this by letting you run JavaScript in real, parallel OS threads — each with its own V8 instance and event loop — so CPU-bound work can proceed without stalling your main thread.

When to use worker threads

Worker threads exist for CPU-bound work: tasks that keep the processor busy rather than waiting on the network or disk. Typical candidates include image and video processing, cryptographic hashing, compression, parsing large files, running machine-learning inference, and any heavy data transformation.

They are the wrong tool for I/O. Reading a file, querying a database, or calling an API already happens asynchronously off the main thread via libuv’s thread pool — spawning a worker for those tasks adds overhead with no benefit. The simple rule:

WorkloadUse
HTTP requests, DB queries, file readsPlain async/await on the main thread
Hashing, image resizing, big loops, parsingWorker threads
Running a separate program or sandboxingChild processes

Spinning up a worker has a real cost (a fresh V8 isolate, memory, startup time). Only reach for them when the computation is heavy enough to justify it — usually tens of milliseconds or more.

Creating a worker

Each worker is backed by a script file. You construct a Worker, point it at that file, and communicate through messages. Here is a worker that computes a Fibonacci number — a deliberately expensive synchronous task.

// fib-worker.js
import { parentPort, workerData } from 'node:worker_threads';

function fib(n) {
  return n < 2 ? n : fib(n - 1) + fib(n - 2);
}

const result = fib(workerData);
parentPort.postMessage(result);

The main module spawns the worker, passes input through workerData, and listens for the result:

// main.js
import { Worker } from 'node:worker_threads';

function runFib(n) {
  return new Promise((resolve, reject) => {
    const worker = new Worker(new URL('./fib-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}`));
      }
    });
  });
}

console.time('fib');
const value = await runFib(42);
console.timeEnd('fib');
console.log(`fib(42) = ${value}`);

Output:

fib: 1043.221ms
fib(42) = 267914296

Crucially, while fib(42) runs inside the worker, the main thread’s event loop stays responsive — timers fire and incoming requests are handled normally.

Use new URL('./file.js', import.meta.url) to resolve worker paths in ES modules. In CommonJS you can pass __filename or a plain absolute path instead.

CommonJS equivalent

If your project uses require, the API is identical — only the imports and path resolution differ:

const { Worker } = require('node:worker_threads');

const worker = new Worker(require.resolve('./fib-worker.js'), {
  workerData: 42,
});

Workers vs. child processes

Both run code in parallel, but they are built for different jobs. Worker threads share the same process and can share memory; child processes are fully separate OS programs.

AspectWorker threadsChild processes
Modulenode:worker_threadsnode:child_process
IsolationThreads in one processSeparate processes
MemoryCan share via SharedArrayBufferFully isolated, no sharing
CommunicationFast message passing, transferable objectsIPC / streams (slower, serialized)
Startup costLowerHigher
Best forCPU-bound JS within your appRunning other programs, isolation, fault tolerance

Reach for worker threads when you want parallel JavaScript that shares your app’s runtime cheaply. Reach for child processes when you need to run a separate executable, want crash isolation, or need to scale across cores by forking whole Node processes (often via the cluster module).

Why workers do not help with I/O

A common misconception is that workers speed up file or network operations. They don’t. Node’s I/O is already non-blocking — await fetch(...) or await readFile(...) hands the work to the OS or libuv’s pool and returns control to the event loop immediately. Wrapping that in a worker just adds thread-startup and message-passing overhead while the actual I/O takes exactly as long.

// Pointless: the worker spends its whole life idle, waiting on the network
import { parentPort } from 'node:worker_threads';
const res = await fetch('https://api.example.com/data');
parentPort.postMessage(await res.json()); // no faster than doing this inline

Save workers for when the CPU itself is the bottleneck.

Best practices

  • Use workers only for genuinely CPU-bound tasks; keep I/O on the main thread with async/await.
  • Reuse workers via a pool rather than creating a new one per task — startup cost adds up fast.
  • Always handle the error and non-zero exit events so a crashing worker does not silently hang a promise.
  • Pass large binary payloads with transferList (transferable ArrayBuffers) or SharedArrayBuffer to avoid copying.
  • Cap concurrency to roughly the number of CPU cores (os.availableParallelism()); more threads than cores just causes contention.
  • Terminate idle or stuck workers with worker.terminate() to free their V8 isolate and memory.
Last updated June 14, 2026
Was this helpful?