Nodejs

Worker Threads for CPU-Intensive Operations

Master Node.js worker threads for CPU-intensive operations with thread pools, shared memory, and practical performance patterns.

Worker Threads for CPU-Intensive Operations

Node.js runs JavaScript on a single thread. That is great for I/O-bound work — handling HTTP requests, reading files, querying databases. But the moment you need to hash passwords, resize images, parse large CSV files, or run machine learning inference, that single thread blocks and your entire server stops responding. Worker threads solve this by running JavaScript in parallel OS threads with proper data sharing. This guide covers the worker_threads module from basic usage to production thread pool patterns.

Prerequisites

  • Node.js 16+ (worker_threads stable since Node 12, but 16+ recommended)
  • Understanding of the Node.js event loop and why it blocks on CPU work
  • Basic familiarity with Promises and callbacks
  • A multi-core machine (workers on a single core still work, but you will not see speedup)

Why Node.js Blocks on CPU Work

The event loop processes one operation at a time. I/O operations are non-blocking because they delegate to the OS kernel or libuv thread pool. But CPU-bound operations run directly on the main thread:

var http = require("http");

var server = http.createServer(function(req, res) {
  if (req.url === "/hash") {
    // This blocks the ENTIRE server for ~2 seconds
    var result = heavyComputation();
    res.end(JSON.stringify({ result: result }));
  } else {
    res.end("OK");
  }
});

function heavyComputation() {
  var hash = 0;
  for (var i = 0; i < 1e9; i++) {
    hash = ((hash << 5) - hash + i) | 0;
  }
  return hash;
}

server.listen(3000);

While heavyComputation() runs, every other request waits. A simple health check at / cannot respond until the computation finishes. Worker threads move that computation off the main thread.

Worker Threads Basics

The worker_threads module gives you true OS-level threads in Node.js. Each worker has its own V8 instance, its own event loop, and its own memory space.

Creating a Worker

main.js:

var { Worker, isMainThread, parentPort } = require("worker_threads");

if (isMainThread) {
  // Main thread: create a worker
  var worker = new Worker(__filename);

  worker.on("message", function(result) {
    console.log("Result from worker:", result);
  });

  worker.on("error", function(err) {
    console.error("Worker error:", err);
  });

  worker.on("exit", function(code) {
    console.log("Worker exited with code:", code);
  });

  worker.postMessage({ iterations: 1e9 });
} else {
  // Worker thread: do the heavy work
  parentPort.on("message", function(data) {
    var hash = 0;
    for (var i = 0; i < data.iterations; i++) {
      hash = ((hash << 5) - hash + i) | 0;
    }
    parentPort.postMessage(hash);
  });
}
$ node main.js
Result from worker: -1937831252
Worker exited with code: 0

Separate Worker Files

Using __filename for both main and worker code works for demos, but in production, use separate files:

compute-worker.js:

var { parentPort, workerData } = require("worker_threads");

// workerData is passed at construction, available immediately
var config = workerData || {};

parentPort.on("message", function(task) {
  var start = Date.now();
  var result = runComputation(task);
  parentPort.postMessage({
    result: result,
    duration: Date.now() - start,
    threadId: require("worker_threads").threadId
  });
});

function runComputation(task) {
  if (task.type === "hash") {
    return computeHash(task.data, task.iterations);
  }
  if (task.type === "fibonacci") {
    return fibonacci(task.n);
  }
  throw new Error("Unknown task type: " + task.type);
}

function computeHash(data, iterations) {
  var crypto = require("crypto");
  var hash = data;
  for (var i = 0; i < iterations; i++) {
    hash = crypto.createHash("sha256").update(hash).digest("hex");
  }
  return hash;
}

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

main.js:

var { Worker } = require("worker_threads");
var path = require("path");

var worker = new Worker(path.join(__dirname, "compute-worker.js"), {
  workerData: { maxIterations: 100000 }
});

worker.postMessage({ type: "hash", data: "hello", iterations: 10000 });

worker.on("message", function(msg) {
  console.log("Hash computed in " + msg.duration + "ms by thread " + msg.threadId);
  console.log("Result:", msg.result.substring(0, 16) + "...");
  worker.terminate();
});

Data Transfer Between Threads

Structured Clone (Default)

By default, postMessage uses the structured clone algorithm. Data is copied between threads:

// Main thread
var data = { users: new Array(100000).fill({ name: "test", age: 25 }) };
worker.postMessage(data);  // copies the entire array — slow for large data

For small payloads (under a few MB), structured clone is fine. For large data, it is a bottleneck.

Transferable Objects

ArrayBuffers can be transferred (zero-copy) instead of cloned:

// Main thread: transfer a buffer
var buffer = new ArrayBuffer(1024 * 1024 * 100);  // 100MB
var view = new Uint8Array(buffer);

// Fill with data
for (var i = 0; i < view.length; i++) {
  view[i] = i % 256;
}

console.log("Before transfer, buffer size:", buffer.byteLength);  // 104857600

// Transfer — buffer moves to worker, main thread loses access
worker.postMessage({ buffer: buffer }, [buffer]);

console.log("After transfer, buffer size:", buffer.byteLength);  // 0

Transfer is instant regardless of size. The tradeoff is that the sending thread loses access to the buffer.

SharedArrayBuffer

For shared memory between threads (no copying, no transfer):

// Main thread
var shared = new SharedArrayBuffer(1024);
var view = new Int32Array(shared);

view[0] = 42;
worker.postMessage({ shared: shared });

// Worker thread
parentPort.on("message", function(data) {
  var view = new Int32Array(data.shared);
  console.log("Shared value:", view[0]);  // 42

  // Atomics for thread-safe operations
  Atomics.add(view, 0, 10);
  console.log("After atomic add:", view[0]);  // 52
});

Use Atomics for thread-safe operations on shared memory. Without atomics, concurrent reads and writes cause race conditions.

// Thread-safe counter with Atomics
var shared = new SharedArrayBuffer(4);
var counter = new Int32Array(shared);

// In each worker:
Atomics.add(counter, 0, 1);  // atomic increment
var value = Atomics.load(counter, 0);  // atomic read

// Wait/notify for synchronization
Atomics.wait(counter, 0, 0);     // block until counter[0] !== 0
Atomics.notify(counter, 0, 1);   // wake one waiting thread

Building a Thread Pool

Creating a new worker per task is expensive (~30ms startup). A thread pool reuses workers:

var { Worker } = require("worker_threads");
var path = require("path");
var os = require("os");

function ThreadPool(workerPath, poolSize) {
  this.workerPath = workerPath;
  this.poolSize = poolSize || os.cpus().length;
  this.workers = [];
  this.queue = [];
  this.activeWorkers = new Map();

  for (var i = 0; i < this.poolSize; i++) {
    this._addWorker();
  }
}

ThreadPool.prototype._addWorker = function() {
  var self = this;
  var worker = new Worker(this.workerPath);

  worker.on("message", function(result) {
    var callback = self.activeWorkers.get(worker);
    self.activeWorkers.delete(worker);

    if (callback) {
      callback.resolve(result);
    }

    // Process next queued task
    if (self.queue.length > 0) {
      var next = self.queue.shift();
      self._runOnWorker(worker, next.task, next);
    }
  });

  worker.on("error", function(err) {
    var callback = self.activeWorkers.get(worker);
    self.activeWorkers.delete(worker);

    if (callback) {
      callback.reject(err);
    }

    // Replace dead worker
    var idx = self.workers.indexOf(worker);
    if (idx !== -1) {
      self.workers.splice(idx, 1);
    }
    self._addWorker();
  });

  this.workers.push(worker);
};

ThreadPool.prototype._runOnWorker = function(worker, task, callbacks) {
  this.activeWorkers.set(worker, callbacks);
  worker.postMessage(task);
};

ThreadPool.prototype.run = function(task) {
  var self = this;
  return new Promise(function(resolve, reject) {
    // Find an idle worker
    var idleWorker = null;
    for (var i = 0; i < self.workers.length; i++) {
      if (!self.activeWorkers.has(self.workers[i])) {
        idleWorker = self.workers[i];
        break;
      }
    }

    if (idleWorker) {
      self._runOnWorker(idleWorker, task, { resolve: resolve, reject: reject });
    } else {
      self.queue.push({ task: task, resolve: resolve, reject: reject });
    }
  });
};

ThreadPool.prototype.destroy = function() {
  return Promise.all(this.workers.map(function(w) {
    return w.terminate();
  }));
};

module.exports = ThreadPool;

Usage with an Express server:

var express = require("express");
var path = require("path");
var ThreadPool = require("./thread-pool");

var app = express();
var pool = new ThreadPool(path.join(__dirname, "compute-worker.js"), 4);

app.get("/hash", function(req, res) {
  var data = req.query.data || "default";

  pool.run({ type: "hash", data: data, iterations: 50000 })
    .then(function(result) {
      res.json(result);
    })
    .catch(function(err) {
      res.status(500).json({ error: err.message });
    });
});

app.get("/health", function(req, res) {
  res.json({ status: "ok" });
});

app.listen(3000, function() {
  console.log("Server running on port 3000 with " + 4 + " worker threads");
});

Now /health responds instantly even while /hash is processing because the computation runs on worker threads, not the main event loop.

Benchmarking Worker Threads

Here is a benchmark comparing single-threaded vs worker threads for CPU-intensive hashing:

var { Worker } = require("worker_threads");
var crypto = require("crypto");
var os = require("os");

var TASKS = 8;
var ITERATIONS = 50000;

function hashSync(data, iterations) {
  var hash = data;
  for (var i = 0; i < iterations; i++) {
    hash = crypto.createHash("sha256").update(hash).digest("hex");
  }
  return hash;
}

// Single-threaded benchmark
function benchSingleThread() {
  var start = Date.now();
  for (var i = 0; i < TASKS; i++) {
    hashSync("task-" + i, ITERATIONS);
  }
  return Date.now() - start;
}

// Multi-threaded benchmark
function benchWorkerThreads() {
  var start = Date.now();
  var promises = [];

  for (var i = 0; i < TASKS; i++) {
    promises.push(new Promise(function(resolve, reject) {
      var w = new Worker("./compute-worker.js");
      w.on("message", function(r) { resolve(r); w.terminate(); });
      w.on("error", reject);
      w.postMessage({ type: "hash", data: "task-" + i, iterations: ITERATIONS });
    }));
  }

  return Promise.all(promises).then(function() {
    return Date.now() - start;
  });
}

console.log("CPUs:", os.cpus().length);
console.log("Tasks:", TASKS, "x", ITERATIONS, "hash iterations each");

var singleTime = benchSingleThread();
console.log("Single-threaded:", singleTime + "ms");

benchWorkerThreads().then(function(multiTime) {
  console.log("Worker threads:  " + multiTime + "ms");
  console.log("Speedup:         " + (singleTime / multiTime).toFixed(1) + "x");
});
$ node benchmark.js
CPUs: 8
Tasks: 8 x 50000 hash iterations each
Single-threaded: 12847ms
Worker threads:  3412ms
Speedup:         3.8x

The speedup scales with CPU cores but is not perfectly linear due to worker creation overhead, thread scheduling, and memory bus contention. On 8 cores, expect 3-6x speedup depending on the workload.

When NOT to Use Worker Threads

Worker threads are wrong for:

  • I/O-bound work: Database queries, HTTP requests, file reads. Node.js already handles these asynchronously. Workers add overhead with no benefit.
  • Simple, fast computations: If the work takes under 5ms, the overhead of messaging between threads is greater than the computation itself.
  • Shared mutable state: If tasks need to read/write the same objects constantly, the synchronization complexity outweighs the parallelism benefit.

Use worker threads when a single task takes more than 50-100ms of pure CPU time and you need the server to remain responsive.

Complete Working Example

A production-ready Express server that processes uploaded CSV files using a thread pool:

csv-worker.js:

var { parentPort } = require("worker_threads");

parentPort.on("message", function(task) {
  try {
    var lines = task.csv.split("\n");
    var headers = lines[0].split(",").map(function(h) { return h.trim(); });
    var results = [];

    for (var i = 1; i < lines.length; i++) {
      if (!lines[i].trim()) continue;
      var values = lines[i].split(",");
      var row = {};
      headers.forEach(function(h, idx) {
        row[h] = values[idx] ? values[idx].trim() : "";
      });

      // Simulate expensive per-row processing
      row._hash = computeRowHash(JSON.stringify(row));
      row._valid = validateRow(row, task.rules || {});
      results.push(row);
    }

    parentPort.postMessage({
      success: true,
      rowCount: results.length,
      rows: results,
      threadId: require("worker_threads").threadId
    });
  } catch (err) {
    parentPort.postMessage({
      success: false,
      error: err.message
    });
  }
});

function computeRowHash(data) {
  var crypto = require("crypto");
  return crypto.createHash("md5").update(data).digest("hex");
}

function validateRow(row, rules) {
  var errors = [];
  Object.keys(rules).forEach(function(field) {
    if (rules[field].required && !row[field]) {
      errors.push(field + " is required");
    }
    if (rules[field].maxLength && row[field] && row[field].length > rules[field].maxLength) {
      errors.push(field + " exceeds max length");
    }
  });
  return { valid: errors.length === 0, errors: errors };
}

server.js:

var express = require("express");
var path = require("path");
var ThreadPool = require("./thread-pool");

var app = express();
app.use(express.text({ limit: "50mb", type: "text/csv" }));

var pool = new ThreadPool(path.join(__dirname, "csv-worker.js"), 4);

app.post("/process-csv", function(req, res) {
  var csv = req.body;
  if (!csv) {
    return res.status(400).json({ error: "No CSV data provided" });
  }

  var start = Date.now();

  pool.run({
    csv: csv,
    rules: { name: { required: true }, email: { required: true, maxLength: 255 } }
  })
  .then(function(result) {
    if (!result.success) {
      return res.status(500).json({ error: result.error });
    }
    res.json({
      rows: result.rowCount,
      duration: Date.now() - start + "ms",
      processedBy: "thread-" + result.threadId,
      sample: result.rows.slice(0, 3)
    });
  })
  .catch(function(err) {
    res.status(500).json({ error: err.message });
  });
});

app.get("/health", function(req, res) {
  res.json({ status: "ok", workers: pool.poolSize });
});

var PORT = process.env.PORT || 3000;
app.listen(PORT, function() {
  console.log("CSV processor on port " + PORT + " with " + pool.poolSize + " workers");
});

process.on("SIGTERM", function() {
  console.log("Shutting down...");
  pool.destroy().then(function() {
    process.exit(0);
  });
});
$ curl -X POST http://localhost:3000/process-csv \
  -H "Content-Type: text/csv" \
  --data-binary @large-file.csv

{
  "rows": 50000,
  "duration": "1847ms",
  "processedBy": "thread-3",
  "sample": [...]
}

Common Issues and Troubleshooting

1. Worker Cannot require() Certain Modules

Error: Cannot find module 'native-addon'
    at Worker.<anonymous>

Cause: Native addons (C++ bindings) may not be safe to load in worker threads. Some older addons do not support multi-threading.

Fix: Check if the module supports workers. If not, use child_process.fork() instead, which gives a full separate process.

2. postMessage Fails with Large Objects

RangeError: Maximum call stack size exceeded
    at structuredClone

Cause: Deep or circular objects exceed the structured clone algorithm's limits.

Fix: Serialize manually with JSON.stringify() or transfer as a Buffer:

var json = JSON.stringify(largeObject);
var buffer = Buffer.from(json);
worker.postMessage({ buffer: buffer.buffer }, [buffer.buffer]);

3. SharedArrayBuffer Not Available

TypeError: SharedArrayBuffer is not a function

Cause: SharedArrayBuffer was disabled in browsers after Spectre. In Node.js it is available by default, but some environments disable it.

Fix: Ensure you are running Node.js (not a browser bundle). If running with --no-harmony-sharedarraybuffer, remove that flag.

4. Worker Thread Memory Leak

FATAL ERROR: CALL_AND_RETRY_LAST Allocation failed - JavaScript heap out of memory

Cause: Workers that process tasks in a loop accumulate memory if large results are not garbage collected. Each V8 instance has its own heap.

Fix: Set worker memory limits and recycle workers periodically:

var worker = new Worker("./worker.js", {
  resourceLimits: {
    maxOldGenerationSizeMb: 256,
    maxYoungGenerationSizeMb: 64
  }
});

Best Practices

  • Use a thread pool, not per-task workers. Worker creation costs ~30ms. A pool of long-lived workers eliminates this overhead.
  • Size the pool to CPU core count. More workers than cores causes thread contention. Use os.cpus().length or cpus.length - 1 to leave one core for the event loop.
  • Transfer large buffers, do not copy them. Use postMessage(data, [transferList]) for ArrayBuffers over 1MB. Transfer is instant; copying is O(n).
  • Set resourceLimits on workers. Prevent a single runaway worker from consuming all system memory. Set max heap size based on your workload.
  • Handle worker crashes and replace dead workers. Workers can segfault or run out of memory. Your pool must detect exits and spawn replacements.
  • Do not use workers for I/O. Database queries, HTTP calls, and file reads are already non-blocking in Node.js. Workers add overhead for no benefit on I/O work.
  • Keep messages small. Structured clone is O(n) — sending 100MB of data between threads defeats the purpose. Process data in chunks or use SharedArrayBuffer.
  • Implement graceful shutdown. On SIGTERM, let active workers finish their current task before terminating. Do not kill workers mid-computation.

References

Powered by Contentful