Worker Threads
Worker threads give Node true parallelism for CPU-bound work inside a single process. Unlike child processes, workers are real threads sharing the same process — so they start faster, use less memory, and can share memory directly via SharedArrayBuffer. The killer use case: run a heavy computation on a worker so it doesn't block the main event loop, keeping your server responsive while the work happens on another core. This page covers creating workers, message passing, transferring vs sharing memory, worker pools, and the crucial "don't use threads for I/O" rule.
The problem workers solve
// ❌ A CPU-heavy task on the main thread freezes the ENTIRE server:
app.get('/report', (req, res) => {
const result = crunchHugeDataset() // 3 seconds of pure CPU
res.json(result) // ...all other requests wait 3s too
})Creating a worker and passing data
main.js
import { Worker } from 'node:worker_threads'
function runWorker(data) {
return new Promise((resolve, reject) => {
const worker = new Worker('./worker.js', { workerData: data })
worker.on('message', resolve) // result from the worker
worker.on('error', reject) // uncaught error in the worker
worker.on('exit', (code) => {
if (code !== 0) reject(new Error(`worker stopped with code ${code}`))
})
})
}
// The main thread stays responsive while the worker computes on another core:
app.get('/report', async (req, res) => {
const result = await runWorker({ dataset: req.query.id })
res.json(result)
})worker.js
import { parentPort, workerData } from 'node:worker_threads'
// Runs on a separate thread — this CPU work does NOT block the main event loop:
const result = crunchHugeDataset(workerData.dataset)
parentPort.postMessage(result) // send the result back to the main threadSharing memory: transfer vs SharedArrayBuffer
Mechanism | Behaviour | Use |
|---|---|---|
Structured clone (default) | Message is copied to the worker | Small/medium data; simplest |
Transferable ( | Ownership moved, zero-copy — sender loses it | Large buffers you don't need on both sides |
| Both threads access the same memory | Hot shared data; needs synchronization ( |
// Transfer a large buffer with zero copy (the main thread can no longer use it): const buf = new ArrayBuffer(50 * 1024 * 1024) worker.postMessage(buf, [buf]) // 2nd arg = transfer list → no copy // Share memory between threads (both see the same bytes): const shared = new SharedArrayBuffer(1024) const view = new Int32Array(shared) Atomics.add(view, 0, 1) // atomic ops prevent data races
Use a worker pool, not a worker per task
// ❌ Spawning a fresh worker per request — thread creation overhead every time:
app.get('/x', async (req, res) => res.json(await runWorker(...))) // new Worker each call
// ✅ Reuse a fixed pool of workers (e.g. with the 'piscina' library):
import Piscina from 'piscina'
const pool = new Piscina({
filename: new URL('./worker.js', import.meta.url).href,
maxThreads: availableParallelism(),
})
app.get('/report', async (req, res) => {
const result = await pool.run({ dataset: req.query.id }) // queued onto a free worker
res.json(result)
})When NOT to use worker threads
Don't use them for I/O — database queries, HTTP calls, and file reads are already async and non-blocking; workers add overhead for zero benefit.
Don't use them for trivial work — the message-passing and thread overhead can exceed the cost of just doing it on the main thread.
Use them for genuine CPU-bound work — heavy computation, crypto, image/video processing, data parsing/compression.
For scaling an HTTP server across cores, use the cluster module or replicas — not worker threads.
For running a separate program, use a child process, not a thread.