Skip to content
Codeloom
Node.js

Node.js Worker Threads: A Practical Guide for CPU-Intensive Tasks

Master worker threads in Node.js to offload CPU-heavy operations. Covers thread pools, SharedArrayBuffer, and real-world patterns.

·6 min read · By Codeloom
Intermediate 12 min read

What you'll learn

  • Why worker threads exist and when to use them
  • How to build a reusable thread pool
  • Sharing memory between threads with SharedArrayBuffer
  • Practical patterns for image processing and hashing

Prerequisites

  • Basic Node.js knowledge
  • Understanding of the event loop
  • Familiarity with Promises and async/await

Node.js is single-threaded by design, which makes it excellent for I/O-bound workloads but problematic when you need to crunch numbers, compress files, or process images. Worker threads let you spin up real OS threads that run JavaScript in parallel without blocking the main event loop. This guide walks through practical patterns you will actually use in production.

Why not just use child processes?

Child processes (child_process.fork) spawn entirely separate Node.js instances with their own V8 heap. They work, but they are expensive. Each child process consumes 30-50 MB of memory just to start. Worker threads share the same process, can share memory via SharedArrayBuffer, and have much lower overhead.

Use child processes when you need full isolation or want to run a different executable. Use worker threads when you need parallel JavaScript execution within the same application.

Basic worker communication

The fundamental pattern is simple: the main thread creates a worker, sends it data, and receives results back.

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);
    worker.on('error', reject);
    worker.on('exit', (code) => {
      if (code !== 0) {
        reject(new Error(`Worker stopped with exit code ${code}`));
      }
    });
  });
}

const result = await runWorker({ numbers: [1, 2, 3, 4, 5] });
console.log('Sum:', result.sum);

worker.js:

import { parentPort, workerData } from 'node:worker_threads';

const sum = workerData.numbers.reduce((a, b) => a + b, 0);

parentPort.postMessage({ sum });

Data passed through workerData and postMessage is serialized using the structured clone algorithm. This means you can send objects, arrays, Maps, Sets, and even ArrayBuffers, but not functions or class instances with methods.

Building a thread pool

Creating a new worker for every task is wasteful. In production, you want a pool of pre-warmed workers that pick up tasks from a queue.

import { Worker } from 'node:worker_threads';
import { cpus } from 'node:os';

class ThreadPool {
  #workers = [];
  #queue = [];
  #activeWorkers = 0;

  constructor(workerPath, poolSize = cpus().length) {
    this.workerPath = workerPath;
    this.poolSize = poolSize;
  }

  run(taskData) {
    return new Promise((resolve, reject) => {
      const task = { taskData, resolve, reject };

      if (this.#activeWorkers < this.poolSize) {
        this.#runTask(task);
      } else {
        this.#queue.push(task);
      }
    });
  }

  #runTask(task) {
    this.#activeWorkers++;
    const worker = new Worker(this.workerPath, {
      workerData: task.taskData,
    });

    worker.on('message', (result) => {
      task.resolve(result);
      this.#activeWorkers--;
      if (this.#queue.length > 0) {
        this.#runTask(this.#queue.shift());
      }
    });

    worker.on('error', (err) => {
      task.reject(err);
      this.#activeWorkers--;
      if (this.#queue.length > 0) {
        this.#runTask(this.#queue.shift());
      }
    });
  }

  async close() {
    // Wait for all tasks to finish
    while (this.#activeWorkers > 0 || this.#queue.length > 0) {
      await new Promise((r) => setTimeout(r, 100));
    }
  }
}

Usage:

const pool = new ThreadPool('./hash-worker.js', 4);

const results = await Promise.all(
  files.map((file) => pool.run({ filePath: file }))
);

await pool.close();

Sharing memory with SharedArrayBuffer

The real power of worker threads over child processes is shared memory. SharedArrayBuffer lets multiple threads read and write the same block of memory without copying.

// main.js
import { Worker } from 'node:worker_threads';

const sharedBuffer = new SharedArrayBuffer(4 * 1024); // 4KB
const view = new Int32Array(sharedBuffer);

// Initialize data
for (let i = 0; i < view.length; i++) {
  view[i] = i;
}

const worker = new Worker('./transform-worker.js', {
  workerData: { buffer: sharedBuffer },
});

worker.on('message', () => {
  // Data was modified in place — no copy needed
  console.log('First 5 values:', Array.from(view.slice(0, 5)));
});
// transform-worker.js
import { parentPort, workerData } from 'node:worker_threads';

const view = new Int32Array(workerData.buffer);

// Double every value in place
for (let i = 0; i < view.length; i++) {
  view[i] *= 2;
}

parentPort.postMessage('done');

When multiple threads write to the same shared buffer, you need synchronization. Use Atomics to prevent race conditions:

// Atomic increment — safe across threads
Atomics.add(view, index, 1);

// Wait until a value changes
Atomics.wait(view, index, expectedValue);

// Notify waiting threads
Atomics.notify(view, index, 1);

Real-world example: parallel image hashing

Here is a practical scenario where you need to compute SHA-256 hashes for a batch of uploaded files without blocking the server.

// hash-worker.js
import { parentPort, workerData } from 'node:worker_threads';
import { createHash } from 'node:crypto';
import { readFile } from 'node:fs/promises';

const { filePath } = workerData;

const content = await readFile(filePath);
const hash = createHash('sha256').update(content).digest('hex');

parentPort.postMessage({ filePath, hash });
// server.js
import express from 'express';
import { ThreadPool } from './thread-pool.js';

const pool = new ThreadPool('./hash-worker.js', 4);
const app = express();

app.post('/upload', async (req, res) => {
  const filePaths = req.body.files;

  const hashes = await Promise.all(
    filePaths.map((fp) => pool.run({ filePath: fp }))
  );

  res.json({ hashes });
});

app.listen(3000);

The server stays responsive because all the file reading and hashing happens on worker threads while the main thread continues handling HTTP requests.

Transferring ownership with transferList

When passing large ArrayBuffers between threads, you can transfer ownership instead of copying. This is a zero-copy operation.

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

// Transfer — buffer becomes unusable in the main thread
worker.postMessage({ data: buffer }, [buffer]);

console.log(buffer.byteLength); // 0 — ownership transferred

This is critical for performance when dealing with large binary data like images or video frames.

When to avoid worker threads

Worker threads are not a silver bullet. Avoid them for:

  • I/O-bound work: The event loop already handles this efficiently with non-blocking I/O. Adding worker threads for database queries or HTTP requests adds complexity without benefit.
  • Simple tasks: If the computation takes less than a few milliseconds, the overhead of creating and communicating with a worker is not worth it.
  • Tasks requiring the full Node.js API: Workers have access to most Node.js APIs, but some modules behave differently or are unavailable.

Good use cases include image processing, PDF generation, data compression, cryptographic operations, CSV/JSON parsing of large files, and machine learning inference.

Key takeaways

Worker threads fill a specific gap in Node.js: CPU-intensive parallelism without the overhead of child processes. Use a thread pool pattern in production, leverage SharedArrayBuffer when you need zero-copy data sharing, and remember that for I/O-bound work, the event loop is still your best tool. Start with os.cpus().length workers and benchmark from there.