NodeJSWorker Threads

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

JS
// ❌ 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
})
CPU-bound work on the main thread blocks every concurrent request — that's exactly what workers fix
Because all your JavaScript runs on [one thread](/nodejs/concurrency-intro), a synchronous CPU-bound operation — a 3-second data crunch, image processing, large cryptographic work, parsing a massive file — occupies that thread for its full duration, during which Node **cannot handle any other request**. Latency spikes across the board and the server appears frozen. Async/await doesn't help: there's nothing to *wait* on, the thread is *working*. The remedy is to move the computation onto a **worker thread** running on a different core, so the main thread stays free to keep serving requests while the heavy work proceeds in parallel.
Creating a worker and passing data

main.js

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

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 thread
`workerData` seeds the worker; `postMessage`/`on('message')` exchange data — wrap it in a Promise for clean async
A `Worker` runs a separate script file on its own thread. You seed it with initial input via `workerData` (available in the worker as an import), and the two sides communicate with `postMessage`/`on('message')`. Wrapping the worker's lifecycle in a Promise (resolve on `message`, reject on `error`/non-zero `exit`) gives you a clean `await`-able interface from your request handlers. While the worker crunches, the main thread keeps processing other requests — that's the parallelism win. Always handle the `error` and `exit` events so a crashing worker rejects rather than hanging the request forever.
Sharing memory: transfer vs SharedArrayBuffer

Mechanism

Behaviour

Use

Structured clone (default)

Message is copied to the worker

Small/medium data; simplest

Transferable (ArrayBuffer)

Ownership moved, zero-copy — sender loses it

Large buffers you don't need on both sides

SharedArrayBuffer

Both threads access the same memory

Hot shared data; needs synchronization (Atomics)

JS
// 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
By default messages are COPIED — transfer or share large buffers to avoid the copy cost
Passing data to a worker uses the **structured clone** algorithm by default, meaning the data is *copied* — fine for small payloads, expensive for large ones. Two optimizations: **transferring** an `ArrayBuffer` (listing it in `postMessage`'s second argument) hands ownership to the worker with zero copy, but the sender can no longer use it. **`SharedArrayBuffer`** gives both threads access to the *same* underlying memory simultaneously — the fastest option for hot shared data, but now you have classic shared-memory concurrency hazards (data races), so you must coordinate access with `Atomics`. Most code uses plain copied messages; reach for transfer/share only when profiling shows the copy is a bottleneck.
Use a worker pool, not a worker per task

JS
// ❌ 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)
})
Don't create a worker per task — pool a fixed number (≈ core count) and queue work onto them
Spinning up a thread has real cost (a new V8 isolate and context), so creating one per request defeats the purpose and can exhaust resources under load. Instead maintain a **pool** of long-lived workers — roughly one per CPU core — and dispatch tasks to whichever worker is free, queueing when all are busy. The `piscina` library implements exactly this with a clean `pool.run(data)` Promise API. Sizing the pool to the core count maximizes parallelism without oversubscribing the CPU; more threads than cores just adds context-switching overhead without speeding anything up.
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.

Worker threads are for CPU work, NOT I/O — using them for database/HTTP calls is a common mistake
The most frequent misuse is offloading **I/O** to worker threads. Database queries, network requests, and file reads are *already* non-blocking on the main thread — Node handles them concurrently without occupying the thread while they wait. Moving them to a worker adds thread and serialization overhead while solving a problem that doesn't exist, and often makes things slower. Worker threads earn their keep **only** for CPU-bound computation that would otherwise block the [event loop](/nodejs/event-loop). If the work is "waiting", keep it async on the main thread; if the work is "calculating", that's when a worker helps.
Next
Bring it together — scaling Node from one box to many: [Scaling Node.js Applications](/nodejs/scaling-strategies).