jsguides

Worker Threads in Node.js

JavaScript is single-threaded by nature. The event loop handles one task at a time, which works beautifully for I/O-bound operations like network requests and file reading. But what happens when you need to perform CPU-intensive computations? Your application freezes, and users wait.

Worker Threads solve this problem. They let you run JavaScript in parallel, fully utilizing multi-core CPUs without blocking the main thread.

Why Worker Threads Matter

Node.js excels at handling concurrent I/O operations, but CPU-bound tasks block the event loop. Consider these scenarios:

  • Image or video processing
  • Cryptographic operations
  • Data compression
  • Machine learning inference
  • Complex mathematical calculations

Without Worker Threads, these operations monopolize the event loop, making your application unresponsive.

Worker Threads provide true parallelism by running JavaScript in separate threads that share minimal data. Each worker has its own V8 instance, event loop, and memory, but can communicate with the main thread through message passing.

Creating Your First Worker Thread

The worker_threads module ships with Node.js—no dependencies needed.

Basic Example

Create a file named worker.js:

// worker.js
const { workerData, parentPort } = require('worker_threads');

// Perform CPU-intensive work
function fibonacci(n) {
  if (n <= 1) return n;
  return fibonacci(n - 1) + fibonacci(n - 2);
}

const result = fibonacci(workerData.number);

// Send result back to main thread
parentPort.postMessage(result);

Now create the main script:

// main.js
const { Worker } = require('worker_threads');
const path = require('path');

const worker = new Worker(path.join(__dirname, 'worker.js'), {
  workerData: { number: 40 }
});

worker.on('message', (result) => {
  console.log(`Fibonacci(40) = ${result}`);
});

worker.on('error', (err) => {
  console.error('Worker error:', err);
});

worker.on('exit', (code) => {
  if (code !== 0) {
    console.error(`Worker stopped with exit code ${code}`);
  }
});

Run it:

node main.js
# Fibonacci(40) = 102334155

The main thread stays responsive while the worker computes Fibonacci(40) in the background.

Passing Data Between Threads

Workers communicate via parentPort.postMessage() and worker.postMessage(). This uses the Structured Clone algorithm, supporting most JavaScript types including:

  • Primitives (strings, numbers, booleans)
  • Objects and arrays
  • TypedArrays and Buffers
  • Error objects

Bidirectional Communication

// main.js
const { Worker } = require('worker_threads');

const worker = new Worker(`
  const { parentPort, workerData } = require('worker_threads');

  parentPort.on('message', (msg) => {
    const result = msg.data * 2;
    parentPort.postMessage({ result });
  });
`);

worker.postMessage({ data: 21 });

worker.on('message', (msg) => {
  console.log('Received:', msg.result); // 42
});

Transferable Objects for Performance

When passing large data like TypedArrays, use transferable objects to avoid copying:

// main.js
const { Worker } = require('worker_threads');

const buffer = new ArrayBuffer(1024 * 1024); // 1MB
const int32Array = new Int32Array(buffer);

const worker = new Worker(`
  const { parentPort, workerData } = require('worker_threads');

  // workerData.buffer is now the buffer (not copied)
  const array = new Int32Array(workerData.buffer);
  console.log('First value:', array[0]);

  parentPort.postMessage('done');
`);

// Transfer ownership (buffer becomes unusable in main thread)
worker.postMessage({ buffer }, [buffer]);

This is crucial for performance when working with large datasets.

Handling Multiple Workers

For parallel task processing, create a pool of workers:

// worker-pool.js
const { Worker } = require('worker_threads');
const os = require('os');

class WorkerPool {
  constructor(workerPath, poolSize = os.cpus().length) {
    this.workerPath = workerPath;
    this.poolSize = poolSize;
    this.workers = [];
    this.queue = [];
    this.init();
  }

  init() {
    for (let i = 0; i < this.poolSize; i++) {
      this.workers.push(this.createWorker());
    }
  }

  createWorker() {
    const worker = new Worker(this.workerPath);
    worker.isAvailable = true;
    return worker;
  }

  runTask(data) {
    return new Promise((resolve, reject) => {
      const task = { data, resolve, reject };

      const availableWorker = this.workers.find(w => w.isAvailable);

      if (availableWorker) {
        this.executeTask(availableWorker, task);
      } else {
        this.queue.push(task);
      }
    });
  }

  executeTask(worker, task) {
    worker.isAvailable = false;

    const handler = (result) => {
      worker.removeListener('message', handler);
      worker.isAvailable = true;
      task.resolve(result);

      // Process queued task
      if (this.queue.length > 0) {
        const nextTask = this.queue.shift();
        this.executeTask(worker, nextTask);
      }
    };

    worker.on('message', handler);
    worker.postMessage(task.data);
  }
}

module.exports = WorkerPool;

Usage:

const WorkerPool = require('./worker-pool');

const pool = new WorkerPool('./compute-worker.js');

const tasks = [40, 39, 38, 37, 36, 35, 34, 33];
const promises = tasks.map(n => pool.runTask({ number: n }));

Promise.all(promises).then(results => {
  console.log('All results:', results);
});

SharedArrayBuffer for Shared Memory

For truly efficient data sharing, use SharedArrayBuffer:

// main.js
const { Worker } = require('worker_threads');

const sharedBuffer = new SharedArrayBuffer(1024);
const sharedArray = new Int32Array(sharedBuffer);
sharedArray[0] = 42;

const worker = new Worker(`
  const { parentPort, workerData } = require('worker_threads');

  // Access shared memory directly
  const array = new Int32Array(workerData.buffer);
  console.log('Read from shared memory:', array[0]);

  array[0] = 100; // Modify shared data
  parentPort.postMessage('modified');
`);

worker.postMessage({ buffer: sharedBuffer });

worker.on('message', () => {
  console.log('Main thread sees:', sharedArray[0]); // 100
});

Note: SharedArrayBuffer requires specific HTTP headers (Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy) when used in browsers. In Node.js, it works out of the box.

Error Handling and Lifecycle

Proper error handling is essential:

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

const worker = new Worker('./task-worker.js');

worker.on('error', (err) => {
  console.error('Uncaught error in worker:', err);
});

worker.on('exit', (code) => {
  if (code !== 0) {
    console.error(`Worker exited with code ${code}`);
    // Optionally restart the worker
  }
});

// Handle uncaught exceptions in the worker
process.on('unhandledRejection', (reason) => {
  console.error('Unhandled rejection in worker:', reason);
});

When to Use Worker Threads

Use Worker Threads when:

  • CPU-intensive calculations block the event loop
  • You need to utilize multiple CPU cores
  • Background data processing is required
  • Image, audio, or video processing

Consider alternatives for other scenarios:

ScenarioSolution
I/O-bound tasksAsync/await, Promises
Simple parallelismchild_process module
MicroservicesSeparate processes or containers
GPU computeWebGPU or GPU.js

Conclusion

Worker Threads help Node.js reach its potential for CPU-intensive workloads. By offloading heavy computations to separate threads, you keep the main event loop responsive while utilizing all available CPU cores.

Choosing The Right Workload

Worker Threads are the right fit when the work is CPU-heavy enough to block the event loop for a noticeable amount of time. That usually means repeated calculations, large transformations, or data processing that would make a request feel sluggish if it ran on the main thread. If the task is mostly waiting on disk or network activity, a worker usually adds more complexity than value because Node.js already handles I/O concurrency well.

It helps to think about workers as a tool for isolation as much as parallelism. A worker has its own memory and event loop, which means it can chew through expensive work without freezing the rest of the app. That separation is useful when the main process has to stay responsive. It also makes the boundaries clearer because the main thread can focus on coordination while the worker handles the expensive part.

Message Flow And Ownership

The best worker code keeps the message protocol simple. The main thread should know what it is sending, what shape of result to expect, and what to do when a task fails. If the worker receives a clear input and returns a clear output, the boundary stays easy to maintain. That also makes the code easier to test because the worker behaves more like a small service than a hidden helper inside the same file.

Ownership matters too. Decide which thread is responsible for creating the worker, handling its lifecycle, and deciding when it should shut down. If that responsibility is spread around, cleanup gets messy and duplicate workers become more likely. A small wrapper around creation and task dispatch often makes the whole arrangement easier to reason about. The code stays calmer because each thread has a well-defined role.

When Not To Use Workers

Workers are not the answer for every slow task. If the work is short, rare, or mostly waiting on external systems, the extra setup is probably not worth it. The process of spinning up a worker, moving data across the boundary, and collecting the result has real overhead. That is fine when the job is big enough, but it can be counterproductive when the task is tiny.

The other thing to watch is data size. Large objects can cost time to clone or transfer, so the boundary should be worth the trip. When the task is truly CPU-bound, the tradeoff is usually good. When it is not, a simpler pattern often wins. That judgment call is part of using workers well.

Key takeaways:

  • Use worker_threads module for CPU-bound tasks
  • Communicate via message passing for simplicity
  • Use transferable objects for large data
  • Implement worker pools for efficient task distribution
  • Consider SharedArrayBuffer for real-time data sharing

With Worker Threads, your Node.js applications can handle computationally intensive workloads without sacrificing the developer experience that makes Node.js great.

Final Worker Note

Workers are most useful when they let the main thread stay calm. If the app can answer requests, update the UI, or keep moving while the worker does the hard part, the extra setup is usually worth it. If not, a simpler approach is often the better answer.

A small worker boundary also gives you a cleaner place to measure cost. If the overhead of moving data is bigger than the work itself, the main thread may be the better home for that task.

That simple check keeps the feature honest and helps you avoid parallelism that only looks useful on paper.

See Also