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:
| Workload | Use |
|---|---|
| HTTP requests, DB queries, file reads | Plain async/await on the main thread |
| Hashing, image resizing, big loops, parsing | Worker threads |
| Running a separate program or sandboxing | Child 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__filenameor 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.
| Aspect | Worker threads | Child processes |
|---|---|---|
| Module | node:worker_threads | node:child_process |
| Isolation | Threads in one process | Separate processes |
| Memory | Can share via SharedArrayBuffer | Fully isolated, no sharing |
| Communication | Fast message passing, transferable objects | IPC / streams (slower, serialized) |
| Startup cost | Lower | Higher |
| Best for | CPU-bound JS within your app | Running 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
errorand non-zeroexitevents so a crashing worker does not silently hang a promise. - Pass large binary payloads with
transferList(transferableArrayBuffers) orSharedArrayBufferto 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.