Skip to content
Codeloom
Node.js

Worker Threads in Node.js

Learn how to use Worker Threads for CPU-intensive tasks in Node.js. Covers thread creation, message passing, SharedArrayBuffer, and thread pools.

·7 min read · By Codeloom
Intermediate 13 min read

What you'll learn

  • Why Node.js needs worker threads
  • How to create workers and communicate via messages
  • Sharing memory with SharedArrayBuffer
  • Building a thread pool for CPU-bound work
  • When to use workers vs child processes vs clustering

Prerequisites

  • Node.js event loop basics
  • Understanding of single-threaded JavaScript
  • Basic knowledge of concurrency concepts

Node.js runs JavaScript on a single thread. This is great for IO-bound work but terrible for CPU-intensive tasks like image processing, cryptography, or data parsing. A single heavy computation blocks the event loop and freezes your entire server. Worker threads solve this by letting you run JavaScript in parallel threads that share memory with the main thread.

The problem

const http = require('http');

function fibonacci(n) {
  if (n <= 1) return n;
  return fibonacci(n - 1) + fibonacci(n - 2);
}

http.createServer((req, res) => {
  // This blocks the event loop for seconds
  const result = fibonacci(45);
  res.end(`Result: ${result}`);
}).listen(3000);

While computing fibonacci(45), the server cannot handle any other requests. Every user waits.

Creating a worker

main.js:

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

const worker = new Worker('./worker.js', {
  workerData: { n: 45 }
});

worker.on('message', (result) => {
  console.log(`Result: ${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}`);
  }
});

worker.js:

const { parentPort, workerData } = require('worker_threads');

function fibonacci(n) {
  if (n <= 1) return n;
  return fibonacci(n - 1) + fibonacci(n - 2);
}

const result = fibonacci(workerData.n);
parentPort.postMessage(result);

The main thread continues handling other work while the worker computes fibonacci in a separate thread.

Inline workers

You can define the worker code inline using a data URL or by checking isMainThread:

const { Worker, isMainThread, parentPort, workerData } = require('worker_threads');

if (isMainThread) {
  // Main thread
  const worker = new Worker(__filename, {
    workerData: { n: 40 }
  });

  worker.on('message', (result) => {
    console.log(`Fibonacci result: ${result}`);
  });
} else {
  // Worker thread
  function fibonacci(n) {
    if (n <= 1) return n;
    return fibonacci(n - 1) + fibonacci(n - 2);
  }

  const result = fibonacci(workerData.n);
  parentPort.postMessage(result);
}

Communication channels

Basic message passing

Messages are serialized using the structured clone algorithm (similar to JSON.parse(JSON.stringify()) but supports more types including Map, Set, Date, RegExp, and ArrayBuffer).

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

// Send a message to the worker
worker.postMessage({ type: 'process', data: [1, 2, 3, 4, 5] });

// Receive messages from the worker
worker.on('message', (msg) => {
  if (msg.type === 'result') {
    console.log('Processed:', msg.data);
  } else if (msg.type === 'progress') {
    console.log(`Progress: ${msg.percent}%`);
  }
});
// worker.js
const { parentPort } = require('worker_threads');

parentPort.on('message', (msg) => {
  if (msg.type === 'process') {
    const data = msg.data;
    const results = [];

    for (let i = 0; i < data.length; i++) {
      results.push(data[i] * 2);
      parentPort.postMessage({
        type: 'progress',
        percent: Math.round(((i + 1) / data.length) * 100)
      });
    }

    parentPort.postMessage({ type: 'result', data: results });
  }
});

MessageChannel for direct worker-to-worker communication

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

const { port1, port2 } = new MessageChannel();

const workerA = new Worker('./workerA.js', {
  workerData: { port: port1 },
  transferList: [port1]
});

const workerB = new Worker('./workerB.js', {
  workerData: { port: port2 },
  transferList: [port2]
});

Shared memory with SharedArrayBuffer

For high-performance scenarios, avoid message passing overhead by sharing memory directly:

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

// Create shared memory (1024 integers)
const sharedBuffer = new SharedArrayBuffer(1024 * Int32Array.BYTES_PER_ELEMENT);
const sharedArray = new Int32Array(sharedBuffer);

// Initialize with data
for (let i = 0; i < 1024; i++) {
  sharedArray[i] = i;
}

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

worker.on('message', () => {
  // Worker modified the shared array in-place
  console.log('First 10 values:', Array.from(sharedArray.slice(0, 10)));
});
// worker.js
const { parentPort, workerData } = require('worker_threads');

const sharedArray = new Int32Array(workerData.buffer);

// Modify in-place (no copying)
for (let i = 0; i < sharedArray.length; i++) {
  sharedArray[i] = sharedArray[i] * 2;
}

parentPort.postMessage('done');

Atomics for thread safety

When multiple threads access shared memory, use Atomics to prevent race conditions:

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

const sharedBuffer = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT);
const counter = new Int32Array(sharedBuffer);

const workers = [];
for (let i = 0; i < 4; i++) {
  const worker = new Worker(`
    const { parentPort, workerData } = require('worker_threads');
    const counter = new Int32Array(workerData.buffer);
    for (let i = 0; i < 10000; i++) {
      Atomics.add(counter, 0, 1); // Thread-safe increment
    }
    parentPort.postMessage('done');
  `, { eval: true, workerData: { buffer: sharedBuffer } });
  workers.push(new Promise(resolve => worker.on('message', resolve)));
}

Promise.all(workers).then(() => {
  console.log('Counter:', counter[0]); // Always 40000
});

Building a thread pool

Creating a new worker for each task is expensive. A thread pool reuses workers:

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

class ThreadPool {
  constructor(workerScript, poolSize = os.cpus().length) {
    this.workerScript = workerScript;
    this.poolSize = poolSize;
    this.workers = [];
    this.queue = [];
    this.activeWorkers = 0;

    for (let i = 0; i < poolSize; i++) {
      this.addWorker();
    }
  }

  addWorker() {
    const worker = new Worker(this.workerScript);
    worker.busy = false;
    worker.on('message', (result) => {
      worker.busy = false;
      worker.currentResolve(result);
      this.activeWorkers--;
      this.processQueue();
    });
    worker.on('error', (err) => {
      worker.busy = false;
      worker.currentReject(err);
      this.activeWorkers--;
      this.processQueue();
    });
    this.workers.push(worker);
  }

  run(data) {
    return new Promise((resolve, reject) => {
      this.queue.push({ data, resolve, reject });
      this.processQueue();
    });
  }

  processQueue() {
    if (this.queue.length === 0) return;

    const availableWorker = this.workers.find(w => !w.busy);
    if (!availableWorker) return;

    const { data, resolve, reject } = this.queue.shift();
    availableWorker.busy = true;
    availableWorker.currentResolve = resolve;
    availableWorker.currentReject = reject;
    this.activeWorkers++;
    availableWorker.postMessage(data);
  }

  destroy() {
    for (const worker of this.workers) {
      worker.terminate();
    }
  }
}

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

async function main() {
  const results = await Promise.all([
    pool.run({ n: 40 }),
    pool.run({ n: 41 }),
    pool.run({ n: 42 }),
    pool.run({ n: 43 }),
  ]);
  console.log(results);
  pool.destroy();
}

main();

Workers vs child processes vs clustering

FeatureWorker ThreadsChild ProcessCluster
Memory sharingYes (SharedArrayBuffer)NoNo
CommunicationFast (postMessage)IPC (serialization)IPC
OverheadLowHigh (new V8 instance)High
Use caseCPU-bound tasksIsolation, different languagesScaling HTTP servers
Module sharingSame context possibleSeparate processSeparate process

Use worker threads for CPU-bound JavaScript tasks that benefit from shared memory. Use child processes when you need full process isolation or need to run non-JavaScript programs. Use clustering when you want to scale an HTTP server across CPU cores.

Real-world example: image processing server

// server.js
const http = require('http');
const ThreadPool = require('./thread-pool');

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

http.createServer(async (req, res) => {
  if (req.url === '/resize') {
    try {
      const result = await pool.run({
        inputPath: './large-image.jpg',
        width: 200,
        height: 200
      });
      res.writeHead(200, { 'Content-Type': 'image/jpeg' });
      res.end(Buffer.from(result.data));
    } catch (err) {
      res.writeHead(500);
      res.end('Processing failed');
    }
  }
}).listen(3000);

The server remains responsive to all requests because image processing happens on worker threads, not the main thread.

Summary

Worker threads let you run CPU-intensive JavaScript in parallel without blocking the event loop. Use postMessage for simple communication and SharedArrayBuffer with Atomics when you need high-performance shared memory. Build a thread pool to amortize the cost of creating workers. Workers complement Node.js’s async IO model: use async IO for network and file operations, and workers for computation.