Skip to main content

What are worker threads in Node.js?

Worker threads in Node.js run JavaScript in parallel threads inside the same process, designed for CPU-intensive tasks that would otherwise block the event loop.

Theory

TL;DR

  • Worker threads live in the same process but execute on separate OS threads
  • They share memory via SharedArrayBuffer, unlike the Cluster module which spawns separate processes
  • Use them for CPU-bound work: image processing, cryptography, heavy parsing
  • For I/O tasks (HTTP requests, file reads), the event loop handles them without threads
  • Experimental in Node.js 10, stable since Node.js 12

Quick example

js
// worker.js const { workerData, parentPort } = require('worker_threads'); let result = 0; for (let i = 0; i < workerData.n; i++) result += Math.sqrt(i); parentPort.postMessage(result); // send result to main thread
js
// main.js const { Worker } = require('worker_threads'); const worker = new Worker('./worker.js', { workerData: { n: 1e8 } }); worker.on('message', result => console.log('Result:', result)); console.log('Main thread keeps running'); // prints immediately

The main thread does not wait. While the worker runs Math.sqrt 100 million times, the main thread stays free to handle requests or other events.

Why the event loop is not enough for CPU work

Node.js runs JavaScript on a single thread. That is intentional. The event loop works well for I/O because waiting happens in libuv's thread pool or at the OS level. The JS thread just processes callbacks when work is done.

CPU work is different. A 4-second hash computation in the main thread holds up every incoming request for those 4 seconds. Worker threads give that computation its own thread, so the main thread stays clear.

How worker threads work internally

Each Worker instance creates a new V8 isolate (a separate JavaScript engine context) with its own event loop. The worker runs completely independently. It has its own module system, globals, and memory heap.

Communication between threads goes through MessagePort. Data is cloned via the structured clone algorithm by default. Passing a 10MB object copies 10MB. That cloning cost adds up under load.

To skip copying, you can transfer ownership of objects with { transfer: [...] }, or use SharedArrayBuffer to give both threads access to the same physical memory.

Shared memory with SharedArrayBuffer

js
const { Worker } = require('worker_threads'); const buffer = new SharedArrayBuffer(4); // 4 bytes, shared between threads const view = new Int32Array(buffer); view[0] = 0; const worker = new Worker(` const { workerData } = require('worker_threads'); const arr = new Int32Array(workerData.buffer); Atomics.store(arr, 0, 42); // thread-safe write `, { eval: true, workerData: { buffer } }); worker.on('exit', () => { console.log(view[0]); // 42 });

Notice Atomics.store. Without it, two threads writing to the same memory location at the same time produce race conditions. Atomics provides thread-safe operations on SharedArrayBuffer.

Worker pool pattern

Creating a new worker per request is expensive. V8 needs to initialize a new isolate each time, which takes 50-100ms. In production, you pre-create a pool of workers and reuse them. After debugging a production incident where missing error handlers caused worker failures to silently hang Promises, I now treat pool implementation as something that needs error handling built in from the start.

js
const { Worker } = require('worker_threads'); const os = require('os'); class WorkerPool { constructor(workerFile, size = os.cpus().length) { this.workers = Array.from({ length: size }, () => ({ worker: new Worker(workerFile), busy: false })); this.queue = []; } run(data) { return new Promise((resolve, reject) => { const free = this.workers.find(w => !w.busy); if (free) { this._execute(free, data, resolve, reject); } else { this.queue.push({ data, resolve, reject }); } }); } _execute(entry, data, resolve, reject) { entry.busy = true; entry.worker.postMessage(data); entry.worker.once('message', result => { entry.busy = false; resolve(result); if (this.queue.length > 0) { const next = this.queue.shift(); this._execute(entry, next.data, next.resolve, next.reject); } }); entry.worker.once('error', reject); } }

Pool size defaults to os.cpus().length because that is how many threads the CPU can run in parallel. More workers than CPU cores adds context switching overhead with no performance benefit.

Worker threads vs Cluster

Worker ThreadsCluster
Process modelSame processSeparate processes
MemoryShared via SharedArrayBufferIsolated
CommunicationMessagePort (fast, structured clone)IPC (slower)
Crash isolationLow (worker error can affect the process)High (process crash stays contained)
Best forCPU-bound computationScaling HTTP servers across CPU cores

Cluster is the right tool when multiple workers need to handle incoming connections. Worker threads are the right tool when one task needs to run heavy computation without blocking everything else.

Common mistakes

1. Using worker threads for I/O

js
// Wrong - spawns a whole thread just to read a file const worker = new Worker('./readFile.js'); // Right - the event loop handles this natively const data = await fs.promises.readFile('./file.txt');

I/O is async by design. A worker here adds overhead with no gain.

2. Skipping Atomics on SharedArrayBuffer

js
// Race condition - reads and writes can interleave shared[0]++; // Thread-safe Atomics.add(shared, 0, 1);

Two threads incrementing the same value without Atomics will lose updates.

3. Creating a new worker per request

js
// Expensive - new V8 isolate on every request app.get('/compute', (req, res) => { const w = new Worker('./compute.js'); w.on('message', result => res.json({ result })); });

Use a pool. Under load, 100ms startup time per request is a serious problem.

4. Copying large buffers through postMessage

js
// Copies 50MB on every call worker.postMessage({ data: hugeBuffer }); // Transfer ownership instead (zero-copy) worker.postMessage({ data: hugeBuffer }, [hugeBuffer]); // hugeBuffer is now detached in the main thread

5. Missing error handling

js
// If the worker throws, this Promise hangs forever const worker = new Worker('./task.js'); worker.on('message', resolve); // Always add both: worker.on('error', reject); worker.on('exit', code => { if (code !== 0) reject(new Error(`Worker stopped with code ${code}`)); });

Where worker threads appear in real codebases

  • sharp (image processing) uses workers for resize and format conversion
  • bcrypt hashing in auth services moves to workers so password checks do not delay API responses
  • Webpack and esbuild use worker pools for parallel module transformation
  • piscina is a widely-used worker thread pool library that replaces hand-rolled pool implementations
  • Jest runs test files in worker threads for parallel test execution

Follow-up questions

Q: What is the difference between workerData and postMessage?
A: workerData is read-only data passed at worker creation time. postMessage sends messages in both directions after the worker is running. Use workerData for initial config, postMessage for ongoing communication.

Q: Can worker threads share the same require cache?
A: No. Each worker has its own V8 isolate and module cache. require('lodash') in a worker loads lodash separately from the main thread. This is part of why worker startup has a measurable cost.

Q: What happens if a worker throws an unhandled error?
A: The worker emits an 'error' event and terminates. If no listener is attached, the error propagates to the main thread as an uncaught exception and crashes the process.

Q: How does SharedArrayBuffer differ from postMessage for data transfer?
A: postMessage copies data via structured clone. A 5MB buffer costs 5MB of extra memory and time to clone. SharedArrayBuffer gives both threads access to the same physical memory with no copying, but requires Atomics for safe concurrent access.

Q: When should you use worker threads versus a child process?
A: Worker threads when the task is compute-heavy and you want low-overhead communication or shared memory. Child processes when you need full isolation (a crash in child_process does not affect the parent) or when running an external executable.

Examples

Basic CPU-bound task off the main thread

js
// hash-worker.js const { workerData, parentPort } = require('worker_threads'); const crypto = require('crypto'); // Synchronous hashing - would block the event loop on the main thread const hash = crypto .createHash('sha256') .update(workerData.payload.repeat(10000)) .digest('hex'); parentPort.postMessage(hash);
js
// server.js const { Worker } = require('worker_threads'); const http = require('http'); function hashInWorker(payload) { return new Promise((resolve, reject) => { const worker = new Worker('./hash-worker.js', { workerData: { payload } }); worker.on('message', resolve); worker.on('error', reject); worker.on('exit', code => { if (code !== 0) reject(new Error(`Worker exited: ${code}`)); }); }); } http.createServer(async (req, res) => { const hash = await hashInWorker('user-password'); res.end(hash); }).listen(3000);

The HTTP server handles every request without blocking. Each hash runs in its own thread. The main thread stays free.

Thread-safe counter with SharedArrayBuffer and Atomics

js
// counter-worker.js const { workerData } = require('worker_threads'); const counter = new Int32Array(workerData.buffer); for (let i = 0; i < 1000; i++) { Atomics.add(counter, 0, 1); // thread-safe increment }
js
// main.js const { Worker } = require('worker_threads'); const buffer = new SharedArrayBuffer(4); const counter = new Int32Array(buffer); const workers = Array.from({ length: 4 }, () => new Worker('./counter-worker.js', { workerData: { buffer } }) ); Promise.all( workers.map(w => new Promise(resolve => w.on('exit', resolve))) ).then(() => { console.log(counter[0]); // exactly 4000 });

Four workers each increment the counter 1000 times. Atomics.add makes sure no update is lost to a race condition.

Worker pool for image resize requests

js
// image-pool.js const { Worker } = require('worker_threads'); const os = require('os'); const path = require('path'); class ImagePool { constructor() { this.pool = Array.from({ length: os.cpus().length }, () => ({ worker: new Worker(path.join(__dirname, 'image-worker.js')), busy: false })); this.pending = []; } resize(imagePath, width, height) { return new Promise((resolve, reject) => { const free = this.pool.find(p => !p.busy); if (free) { this._dispatch(free, { imagePath, width, height }, resolve, reject); } else { this.pending.push({ data: { imagePath, width, height }, resolve, reject }); } }); } _dispatch(entry, data, resolve, reject) { entry.busy = true; entry.worker.postMessage(data); entry.worker.once('message', result => { entry.busy = false; resolve(result); if (this.pending.length > 0) { const next = this.pending.shift(); this._dispatch(entry, next.data, next.resolve, next.reject); } }); entry.worker.once('error', err => { entry.busy = false; reject(err); }); } } module.exports = new ImagePool();

One pool instance shared across the whole application. Resize requests queue up and get processed as workers become free, without spawning new processes on each call.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?