Nodejs

Worker Threads for CPU-Intensive Operations

A practical guide to Node.js worker threads for offloading CPU-intensive tasks covering thread pools, shared memory, message passing, and Express.js integration.

Worker Threads for CPU-Intensive Operations

Overview

Node.js is single-threaded by design, which means any CPU-intensive operation blocks the event loop and kills your server's ability to handle concurrent requests. The worker_threads module, stable since Node.js 12, gives you actual OS-level threads that share the same process memory space, letting you offload heavy computation without the overhead of spawning child processes. If you are running image processing, PDF generation, cryptographic hashing, large JSON parsing, or any computation that takes more than a few milliseconds, worker threads are the correct tool for the job.

Prerequisites

  • Node.js 12 or later (14+ recommended for full API stability)
  • Solid understanding of the Node.js event loop and asynchronous patterns
  • Familiarity with CommonJS modules and callback/promise patterns
  • Basic understanding of concurrency concepts (threads, shared memory, race conditions)

Why the Event Loop Blocks on CPU Work

Node.js runs your JavaScript on a single thread called the main thread. The event loop on that thread processes I/O callbacks, timers, and microtasks. When you perform an asynchronous I/O operation like reading a file or making an HTTP request, Node.js delegates that work to the operating system or to libuv's internal thread pool and your JavaScript keeps running.

CPU-bound work is different. There is nowhere to delegate it. When you run a tight loop computing a Fibonacci number, hashing a large buffer, or parsing a 200MB CSV file, the main thread is occupied and cannot process anything else. Every incoming HTTP request sits in a queue. Every timer callback waits. Every WebSocket message goes undelivered.

Here is a simple demonstration of the problem:

var http = require("http");

function heavyComputation(iterations) {
  var result = 0;
  for (var i = 0; i < iterations; i++) {
    result += Math.sqrt(i) * Math.sin(i);
  }
  return result;
}

var server = http.createServer(function (req, res) {
  if (req.url === "/heavy") {
    var start = Date.now();
    var result = heavyComputation(1e9);
    var elapsed = Date.now() - start;
    res.end("Result: " + result + " (took " + elapsed + "ms)");
  } else {
    res.end("OK");
  }
});

server.listen(3000);

Hit /heavy and then immediately hit / in another tab. The simple / response will not return until the heavy computation finishes. On a typical machine, that 1e9 iteration loop takes 3-5 seconds. During that time, your server is completely unresponsive. In production with real traffic, this is catastrophic.

The worker_threads Module Basics

The worker_threads module provides three core primitives: the Worker class for spawning threads, parentPort for communicating back to the main thread, and workerData for passing initialization data to the worker.

Creating a Basic Worker

main.js:

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

if (isMainThread) {
  console.log("Main thread PID:", process.pid);

  var worker = new Worker("./worker.js", {
    workerData: { iterations: 1e9 }
  });

  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);
  });

  console.log("Main thread is free to do other work...");
} else {
  console.log("This code runs in the main thread context");
}

worker.js:

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

function heavyComputation(iterations) {
  var result = 0;
  for (var i = 0; i < iterations; i++) {
    result += Math.sqrt(i) * Math.sin(i);
  }
  return result;
}

var result = heavyComputation(workerData.iterations);
parentPort.postMessage({ result: result, iterations: workerData.iterations });

Output:

Main thread PID: 24581
Main thread is free to do other work...
Result from worker: { result: -323844.2891034527, iterations: 1000000000 }
Worker exited with code: 0

The key insight is that the main thread prints "Main thread is free to do other work..." immediately. The computation runs on a separate OS thread. The main thread's event loop remains unblocked.

Inline Workers

You can define workers inline using the eval option or by passing code directly. This is useful for simple tasks where a separate file feels like overkill:

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

var workerCode = `
  var { parentPort, workerData } = require("worker_threads");
  var crypto = require("crypto");
  var hash = crypto.createHash("sha256").update(workerData.input).digest("hex");
  parentPort.postMessage(hash);
`;

var worker = new Worker(workerCode, {
  eval: true,
  workerData: { input: "hash this string" }
});

worker.on("message", function (hash) {
  console.log("SHA-256:", hash);
});

I generally avoid inline workers in production code. Separate files are easier to debug, test, and maintain. But for one-off scripts or prototyping, inline workers are convenient.

Message Passing Between Threads

Communication between the main thread and worker threads happens through message passing via postMessage() and the message event. Data is serialized using the structured clone algorithm, which handles most JavaScript types including Date, RegExp, Map, Set, ArrayBuffer, and typed arrays.

// main.js
var { Worker } = require("worker_threads");

var worker = new Worker("./processor.js");

// Send work to the worker
worker.postMessage({ type: "parse", payload: '{"users":[{"name":"Alice"},{"name":"Bob"}]}' });
worker.postMessage({ type: "transform", payload: [1, 2, 3, 4, 5] });

worker.on("message", function (msg) {
  console.log("Received:", msg.type, msg.result);
});
// processor.js
var { parentPort } = require("worker_threads");

parentPort.on("message", function (msg) {
  if (msg.type === "parse") {
    var parsed = JSON.parse(msg.payload);
    parentPort.postMessage({ type: "parse", result: parsed });
  } else if (msg.type === "transform") {
    var transformed = msg.payload.map(function (n) { return n * n; });
    parentPort.postMessage({ type: "transform", result: transformed });
  }
});

The Cost of Structured Cloning

Structured cloning creates a deep copy of the data. For small payloads this is negligible, but for large buffers or complex objects, the serialization overhead adds up. On a 100MB Buffer, structured cloning alone can take 200-400ms. This is where SharedArrayBuffer and transfer lists become essential.

SharedArrayBuffer and Atomics

SharedArrayBuffer creates a block of memory that is literally shared between threads. No copying occurs. Both the main thread and the worker thread see the same bytes at the same memory addresses. This is the fastest possible way to share data between threads, but it comes with all the complexity of shared memory programming.

// main.js
var { Worker } = require("worker_threads");

// Allocate shared memory: 1024 integers (4 bytes each)
var sharedBuffer = new SharedArrayBuffer(1024 * 4);
var sharedArray = new Int32Array(sharedBuffer);

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

var worker = new Worker("./shared-worker.js", {
  workerData: { buffer: sharedBuffer }
});

worker.on("message", function (msg) {
  if (msg === "done") {
    // The worker modified the shared memory in-place
    console.log("First 10 values:", Array.from(sharedArray.slice(0, 10)));
    // Output: First 10 values: [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
  }
});
// shared-worker.js
var { parentPort, workerData } = require("worker_threads");

var sharedArray = new Int32Array(workerData.buffer);

// Square every value in-place
for (var i = 0; i < sharedArray.length; i++) {
  sharedArray[i] = sharedArray[i] * sharedArray[i];
}

parentPort.postMessage("done");

Atomics for Synchronization

When multiple threads read and write shared memory, you need atomic operations to prevent race conditions. The Atomics object provides atomic read-modify-write operations and wait/notify primitives:

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

var sharedBuffer = new SharedArrayBuffer(4);
var counter = new Int32Array(sharedBuffer);
counter[0] = 0;

var workers = [];
for (var i = 0; i < 4; i++) {
  var w = new Worker(`
    var { parentPort, workerData } = require("worker_threads");
    var counter = new Int32Array(workerData.buffer);
    for (var j = 0; j < 10000; j++) {
      Atomics.add(counter, 0, 1);
    }
    parentPort.postMessage("done");
  `, { eval: true, workerData: { buffer: sharedBuffer } });
  workers.push(w);
}

var finished = 0;
workers.forEach(function (w) {
  w.on("message", function () {
    finished++;
    if (finished === 4) {
      console.log("Counter:", counter[0]);
      // Output: Counter: 40000 (always correct with Atomics)
    }
  });
});

Without Atomics.add, the counter would often be less than 40000 due to lost updates from concurrent non-atomic read-modify-write cycles.

Transferring Data Efficiently with transferList

When you need to pass large ArrayBuffer or MessagePort objects to a worker, you can transfer ownership instead of copying. A transferred buffer becomes unusable in the sending thread (it is "neutered") and becomes available in the receiving thread at zero copy cost.

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

var worker = new Worker("./image-worker.js");

// Simulate a large image buffer (50MB)
var imageBuffer = new ArrayBuffer(50 * 1024 * 1024);
var view = new Uint8Array(imageBuffer);
for (var i = 0; i < view.length; i++) {
  view[i] = i % 256;
}

console.log("Buffer size before transfer:", imageBuffer.byteLength);
// Output: Buffer size before transfer: 52428800

// Transfer ownership - zero copy
worker.postMessage({ image: imageBuffer }, [imageBuffer]);

console.log("Buffer size after transfer:", imageBuffer.byteLength);
// Output: Buffer size after transfer: 0

The transfer is essentially instant regardless of buffer size, whereas structured cloning a 50MB buffer would take measurable time. Use transfer lists whenever you do not need the data in the sending thread after the transfer.

Worker Thread Pools

Creating a new worker thread for every task is expensive. Thread creation involves allocating a V8 isolate, initializing the runtime, and loading your worker script. On a typical machine, spawning a worker takes 30-50ms. For a web server handling hundreds of requests per second, that overhead is unacceptable.

The solution is a thread pool: create a fixed number of workers at startup and dispatch tasks to idle workers. Here is a production-quality thread pool implementation:

// thread-pool.js
var { Worker } = require("worker_threads");
var { EventEmitter } = require("events");
var os = require("os");

function ThreadPool(workerFile, options) {
  EventEmitter.call(this);
  var self = this;

  self.workerFile = workerFile;
  self.size = (options && options.size) || os.cpus().length;
  self.taskTimeout = (options && options.taskTimeout) || 30000;
  self.workers = [];
  self.idleWorkers = [];
  self.taskQueue = [];
  self.taskMap = new Map();
  self.taskId = 0;
  self.shuttingDown = false;

  for (var i = 0; i < self.size; i++) {
    self._createWorker();
  }
}

ThreadPool.prototype = Object.create(EventEmitter.prototype);
ThreadPool.prototype.constructor = ThreadPool;

ThreadPool.prototype._createWorker = function () {
  var self = this;
  var worker = new Worker(self.workerFile);

  worker.on("message", function (msg) {
    var task = self.taskMap.get(msg.taskId);
    if (task) {
      clearTimeout(task.timer);
      self.taskMap.delete(msg.taskId);
      if (msg.error) {
        task.reject(new Error(msg.error));
      } else {
        task.resolve(msg.result);
      }
    }
    self._assignNextTask(worker);
  });

  worker.on("error", function (err) {
    self.emit("workerError", err);
    var idx = self.workers.indexOf(worker);
    if (idx !== -1) {
      self.workers.splice(idx, 1);
    }
    var idleIdx = self.idleWorkers.indexOf(worker);
    if (idleIdx !== -1) {
      self.idleWorkers.splice(idleIdx, 1);
    }
    if (!self.shuttingDown) {
      self._createWorker();
    }
  });

  worker.on("exit", function (code) {
    if (code !== 0 && !self.shuttingDown) {
      self.emit("workerExit", code);
      var idx = self.workers.indexOf(worker);
      if (idx !== -1) {
        self.workers.splice(idx, 1);
      }
      self._createWorker();
    }
  });

  self.workers.push(worker);
  self.idleWorkers.push(worker);
};

ThreadPool.prototype._assignNextTask = function (worker) {
  var self = this;
  if (self.taskQueue.length > 0) {
    var task = self.taskQueue.shift();
    self._runTask(worker, task);
  } else {
    self.idleWorkers.push(worker);
  }
};

ThreadPool.prototype._runTask = function (worker, task) {
  var self = this;
  var idx = self.idleWorkers.indexOf(worker);
  if (idx !== -1) {
    self.idleWorkers.splice(idx, 1);
  }

  task.timer = setTimeout(function () {
    self.taskMap.delete(task.id);
    task.reject(new Error("Task timed out after " + self.taskTimeout + "ms"));
    worker.terminate().then(function () {
      var wIdx = self.workers.indexOf(worker);
      if (wIdx !== -1) {
        self.workers.splice(wIdx, 1);
      }
      if (!self.shuttingDown) {
        self._createWorker();
      }
    });
  }, self.taskTimeout);

  self.taskMap.set(task.id, task);
  worker.postMessage({ taskId: task.id, type: task.type, data: task.data });
};

ThreadPool.prototype.runTask = function (type, data) {
  var self = this;
  if (self.shuttingDown) {
    return Promise.reject(new Error("Thread pool is shutting down"));
  }

  return new Promise(function (resolve, reject) {
    var task = {
      id: ++self.taskId,
      type: type,
      data: data,
      resolve: resolve,
      reject: reject,
      timer: null
    };

    if (self.idleWorkers.length > 0) {
      var worker = self.idleWorkers[0];
      self._runTask(worker, task);
    } else {
      self.taskQueue.push(task);
    }
  });
};

ThreadPool.prototype.getStats = function () {
  return {
    totalWorkers: this.workers.length,
    idleWorkers: this.idleWorkers.length,
    busyWorkers: this.workers.length - this.idleWorkers.length,
    queuedTasks: this.taskQueue.length
  };
};

ThreadPool.prototype.shutdown = function () {
  var self = this;
  self.shuttingDown = true;

  self.taskQueue.forEach(function (task) {
    clearTimeout(task.timer);
    task.reject(new Error("Thread pool shutting down"));
  });
  self.taskQueue = [];

  return Promise.all(self.workers.map(function (w) {
    return w.terminate();
  })).then(function () {
    self.workers = [];
    self.idleWorkers = [];
    self.taskMap.clear();
  });
};

module.exports = ThreadPool;

This pool handles worker crashes by automatically replacing dead workers, enforces task timeouts, and provides queue management when all workers are busy.

Comparing worker_threads vs child_process vs cluster

Node.js offers three mechanisms for parallel execution. They serve different purposes and have different tradeoffs:

Feature worker_threads child_process cluster
Memory isolation Shared process memory Separate process memory Separate process memory
Communication overhead Low (structured clone or shared memory) High (IPC serialization) High (IPC serialization)
Startup time ~30-50ms ~100-300ms ~100-300ms
SharedArrayBuffer support Yes No No
Can run different scripts Yes Yes No (runs same script)
Use case CPU-intensive tasks Running external programs, isolation Scaling HTTP servers across cores
Memory overhead ~5-10MB per thread ~30-50MB per process ~30-50MB per process

Use worker_threads when you need to offload CPU work within a single application and want low-overhead communication. Use child_process when you need process-level isolation or need to run external binaries. Use cluster when you want to scale an HTTP server across all CPU cores.

In practice, many production Node.js applications combine cluster at the top level (one worker process per core) with worker_threads inside each cluster worker for CPU-intensive tasks. This gives you both horizontal HTTP scaling and per-request computation offloading.

Real-World Use Cases

Image Processing

// image-resize-worker.js
var { parentPort } = require("worker_threads");
var sharp = require("sharp");

parentPort.on("message", function (msg) {
  sharp(msg.data.inputBuffer)
    .resize(msg.data.width, msg.data.height, { fit: "cover" })
    .jpeg({ quality: 80 })
    .toBuffer()
    .then(function (outputBuffer) {
      parentPort.postMessage(
        { taskId: msg.taskId, result: outputBuffer.buffer },
        [outputBuffer.buffer]
      );
    })
    .catch(function (err) {
      parentPort.postMessage({ taskId: msg.taskId, error: err.message });
    });
});

Cryptographic Hashing

// hash-worker.js
var { parentPort } = require("worker_threads");
var crypto = require("crypto");

parentPort.on("message", function (msg) {
  var start = Date.now();

  if (msg.data.type === "bcrypt-like") {
    // Simulate expensive key derivation
    var result = crypto.pbkdf2Sync(
      msg.data.input,
      msg.data.salt || "default-salt",
      msg.data.iterations || 100000,
      64,
      "sha512"
    );
    parentPort.postMessage({
      taskId: msg.taskId,
      result: {
        hash: result.toString("hex"),
        elapsed: Date.now() - start
      }
    });
  } else if (msg.data.type === "sha256") {
    var hash = crypto.createHash("sha256")
      .update(msg.data.input)
      .digest("hex");
    parentPort.postMessage({
      taskId: msg.taskId,
      result: { hash: hash, elapsed: Date.now() - start }
    });
  }
});

Large Data Parsing

// csv-parser-worker.js
var { parentPort, workerData } = require("worker_threads");

function parseCSVChunk(csvText) {
  var lines = csvText.split("\n");
  var headers = lines[0].split(",").map(function (h) { return h.trim(); });
  var records = [];

  for (var i = 1; i < lines.length; i++) {
    if (!lines[i].trim()) continue;
    var values = lines[i].split(",");
    var record = {};
    for (var j = 0; j < headers.length; j++) {
      record[headers[j]] = values[j] ? values[j].trim() : "";
    }
    records.push(record);
  }

  return records;
}

var records = parseCSVChunk(workerData.csvChunk);
parentPort.postMessage({ records: records, count: records.length });

PDF Generation

// pdf-worker.js
var { parentPort } = require("worker_threads");
var PDFDocument = require("pdfkit");

parentPort.on("message", function (msg) {
  var chunks = [];
  var doc = new PDFDocument();

  doc.on("data", function (chunk) { chunks.push(chunk); });
  doc.on("end", function () {
    var pdfBuffer = Buffer.concat(chunks);
    parentPort.postMessage(
      { taskId: msg.taskId, result: pdfBuffer.buffer },
      [pdfBuffer.buffer]
    );
  });

  doc.fontSize(24).text(msg.data.title, { align: "center" });
  doc.moveDown();
  doc.fontSize(12).text(msg.data.body);
  doc.end();
});

Error Handling in Worker Threads

Worker threads can fail in several ways, and robust error handling is critical. Uncaught exceptions, unhandled rejections, and explicit termination all need different handling:

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

var worker = new Worker("./risky-worker.js");

// Catches throw statements and uncaught exceptions in the worker
worker.on("error", function (err) {
  console.error("Worker threw an error:", err.message);
  console.error("Stack:", err.stack);
});

// Fires when the worker thread exits
worker.on("exit", function (code) {
  if (code !== 0) {
    console.error("Worker stopped with exit code", code);
  }
});

// Fires for online/offline state changes
worker.on("online", function () {
  console.log("Worker is online and ready");
});

Inside workers, you should catch errors explicitly and communicate them back via message passing rather than letting them become uncaught exceptions:

// risky-worker.js
var { parentPort } = require("worker_threads");

parentPort.on("message", function (msg) {
  try {
    var result = riskyOperation(msg.data);
    parentPort.postMessage({ taskId: msg.taskId, result: result });
  } catch (err) {
    parentPort.postMessage({ taskId: msg.taskId, error: err.message });
  }
});

process.on("uncaughtException", function (err) {
  parentPort.postMessage({ fatal: true, error: err.message });
  process.exit(1);
});

Resource Limits and Thread Termination

Worker threads accept resource limit options that constrain their memory usage. This prevents a runaway worker from consuming all available memory:

var worker = new Worker("./worker.js", {
  resourceLimits: {
    maxOldGenerationSizeMb: 128,   // V8 old space limit
    maxYoungGenerationSizeMb: 32,  // V8 young space limit
    codeRangeSizeMb: 16,           // V8 code range limit
    stackSizeMb: 4                 // Stack size limit
  }
});

worker.on("error", function (err) {
  // This fires if the worker exceeds memory limits
  console.error("Worker hit resource limit:", err.message);
});

You can also terminate workers forcefully:

// Graceful: ask the worker to stop
worker.postMessage({ type: "shutdown" });

// Forceful: terminate immediately (returns a promise)
worker.terminate().then(function (exitCode) {
  console.log("Worker terminated with code:", exitCode);
});

Forceful termination destroys the V8 isolate and cleans up resources. However, if the worker was in the middle of writing to a SharedArrayBuffer, that memory may be left in an inconsistent state. Always design your shared memory protocols to be resilient to partial writes.

Performance: Thread Creation vs Thread Pools

To illustrate why thread pools matter, consider this benchmark:

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

var TASK_COUNT = 100;
var poolSize = 4;

// Approach 1: New worker per task
function benchmarkNewWorkers() {
  var start = Date.now();
  var completed = 0;

  return new Promise(function (resolve) {
    for (var i = 0; i < TASK_COUNT; i++) {
      var w = new Worker(`
        var { parentPort } = require("worker_threads");
        var sum = 0;
        for (var j = 0; j < 1e6; j++) sum += Math.sqrt(j);
        parentPort.postMessage(sum);
      `, { eval: true });

      w.on("message", function () {
        completed++;
        if (completed === TASK_COUNT) {
          resolve(Date.now() - start);
        }
      });
    }
  });
}

benchmarkNewWorkers().then(function (elapsed) {
  console.log("New worker per task: " + elapsed + "ms");
  // Typical output: New worker per task: 4800ms
});

Compare that against a thread pool approach where workers are reused. The pool version typically runs 3-5x faster for short tasks because it avoids repeated thread creation overhead. For the same 100 tasks on 4 threads, a pool finishes in roughly 1000-1500ms versus 4000-5000ms for the spawn-per-task approach.

Integrating Worker Threads with Express.js

Here is the complete integration pattern for an Express.js API server that uses a thread pool to handle CPU-intensive requests without blocking the event loop:

pool-worker.js (the shared worker script for the pool):

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

var handlers = {
  hash: function (data) {
    var hash = crypto.pbkdf2Sync(
      data.input,
      data.salt || "default",
      data.iterations || 100000,
      64,
      "sha512"
    );
    return { hash: hash.toString("hex") };
  },

  fibonacci: function (data) {
    function fib(n) {
      if (n <= 1) return n;
      return fib(n - 1) + fib(n - 2);
    }
    return { result: fib(data.n), n: data.n };
  },

  primes: function (data) {
    function sieve(limit) {
      var flags = new Uint8Array(limit + 1);
      flags.fill(1);
      flags[0] = 0;
      flags[1] = 0;
      for (var i = 2; i * i <= limit; i++) {
        if (flags[i]) {
          for (var j = i * i; j <= limit; j += i) {
            flags[j] = 0;
          }
        }
      }
      var primes = [];
      for (var k = 0; k < flags.length; k++) {
        if (flags[k]) primes.push(k);
      }
      return primes;
    }
    var primes = sieve(data.limit || 1000000);
    return { count: primes.length, largest: primes[primes.length - 1] };
  },

  json_parse: function (data) {
    var start = Date.now();
    var parsed = JSON.parse(data.jsonString);
    var keys = Object.keys(parsed);
    return {
      keyCount: keys.length,
      parseTime: Date.now() - start
    };
  }
};

parentPort.on("message", function (msg) {
  try {
    var handler = handlers[msg.type];
    if (!handler) {
      parentPort.postMessage({ taskId: msg.taskId, error: "Unknown task type: " + msg.type });
      return;
    }
    var result = handler(msg.data);
    parentPort.postMessage({ taskId: msg.taskId, result: result });
  } catch (err) {
    parentPort.postMessage({ taskId: msg.taskId, error: err.message });
  }
});

server.js (the Express.js application):

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

var app = express();
app.use(express.json({ limit: "10mb" }));

// Initialize thread pool with workers matching CPU cores
var pool = new ThreadPool(path.join(__dirname, "pool-worker.js"), {
  size: 4,        // adjust based on your CPU cores
  taskTimeout: 15000  // 15 second timeout
});

pool.on("workerError", function (err) {
  console.error("Worker error:", err.message);
});

// Health check endpoint (always fast, never blocked)
app.get("/health", function (req, res) {
  var stats = pool.getStats();
  res.json({
    status: "ok",
    pool: stats
  });
});

// CPU-intensive: password hashing
app.post("/api/hash", function (req, res) {
  var start = Date.now();
  pool.runTask("hash", {
    input: req.body.password,
    salt: req.body.salt,
    iterations: req.body.iterations || 100000
  }).then(function (result) {
    result.elapsed = Date.now() - start;
    res.json(result);
  }).catch(function (err) {
    res.status(500).json({ error: err.message });
  });
});

// CPU-intensive: compute Fibonacci
app.get("/api/fibonacci/:n", function (req, res) {
  var n = parseInt(req.params.n, 10);
  if (n > 45) {
    return res.status(400).json({ error: "n must be <= 45 to avoid excessive computation" });
  }

  pool.runTask("fibonacci", { n: n }).then(function (result) {
    res.json(result);
  }).catch(function (err) {
    res.status(500).json({ error: err.message });
  });
});

// CPU-intensive: prime sieve
app.get("/api/primes/:limit", function (req, res) {
  var limit = parseInt(req.params.limit, 10);
  if (limit > 100000000) {
    return res.status(400).json({ error: "Limit too high" });
  }

  pool.runTask("primes", { limit: limit }).then(function (result) {
    res.json(result);
  }).catch(function (err) {
    res.status(500).json({ error: err.message });
  });
});

// CPU-intensive: parse large JSON
app.post("/api/parse-json", function (req, res) {
  pool.runTask("json_parse", {
    jsonString: JSON.stringify(req.body.data)
  }).then(function (result) {
    res.json(result);
  }).catch(function (err) {
    res.status(500).json({ error: err.message });
  });
});

// Pool statistics
app.get("/api/pool-stats", function (req, res) {
  res.json(pool.getStats());
});

// Graceful shutdown
function gracefulShutdown(signal) {
  console.log("Received " + signal + ". Shutting down gracefully...");
  pool.shutdown().then(function () {
    console.log("Thread pool shut down");
    process.exit(0);
  });
}

process.on("SIGTERM", function () { gracefulShutdown("SIGTERM"); });
process.on("SIGINT", function () { gracefulShutdown("SIGINT"); });

var PORT = process.env.PORT || 3000;
app.listen(PORT, function () {
  console.log("Server running on port " + PORT);
  console.log("Thread pool size: " + pool.getStats().totalWorkers);
});

Test it with curl:

# Hash a password (takes ~200ms on the worker, main thread stays responsive)
curl -X POST http://localhost:3000/api/hash \
  -H "Content-Type: application/json" \
  -d '{"password": "mysecret", "salt": "random-salt", "iterations": 100000}'

# Response:
# {"hash":"a1b2c3d4...","elapsed":187}

# Check pool status while hash is computing
curl http://localhost:3000/api/pool-stats

# Response:
# {"totalWorkers":4,"idleWorkers":3,"busyWorkers":1,"queuedTasks":0}

# Compute primes up to 10 million
curl http://localhost:3000/api/primes/10000000

# Response:
# {"count":664579,"largest":9999991}

The critical point is that while one worker thread is computing a hash or sieving primes, the Express.js event loop remains fully responsive. Health checks, other API calls, and WebSocket connections all continue to function normally.

Common Issues and Troubleshooting

1. "Cannot use import statement outside a module"

SyntaxError: Cannot use import statement outside a module
    at wrapSafe (internal/modules/cjs/loader.js:915:16)
    at Module._compile (internal/modules/cjs/loader.js:963:27)

Worker threads load scripts using CommonJS by default. If your worker file uses import syntax, you need to either rename it to .mjs or add "type": "module" to your package.json. For consistency with the rest of a CommonJS project, just use require().

2. "DataCloneError: Failed to execute structuredClone"

DOMException [DataCloneError]: function () { } could not be cloned.
    at Worker.postMessage (internal/worker.js:421:5)

The structured clone algorithm cannot serialize functions, DOM nodes, or Error objects (prior to Node.js 18). If you need to pass behavior to a worker, pass the function name or a configuration object and let the worker look up the function internally. For errors, serialize them to plain objects with message and stack properties.

3. Worker requires module that uses native addons

Error: Module did not self-register: '/app/node_modules/sharp/build/Release/sharp.node'

Some native addons are not thread-safe or need special loading in worker contexts. The sharp image library, for example, works fine in workers but must be require()-ed inside the worker file, not in the main thread and passed in. Each worker thread has its own V8 isolate and must load native addons independently.

4. SharedArrayBuffer not available

ReferenceError: SharedArrayBuffer is not defined

Since Spectre and Meltdown, SharedArrayBuffer requires specific security headers in browser contexts. In Node.js, it should always be available in versions 12+. If you see this error, check that you are not running with the --no-harmony-sharedarraybuffer flag, and verify your Node.js version is 12 or later with node --version. Also ensure your Node.js build was compiled with SharedArrayBuffer support (all official builds include it).

5. Memory leak from unreferenced workers

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

If you spawn workers in a loop without properly cleaning them up, you will leak memory. Every worker allocates its own V8 heap (typically 5-10MB minimum). Spawning 1000 workers consumes 5-10GB. Always terminate workers when they are no longer needed, and prefer thread pools over spawn-per-task patterns.

6. Worker thread hangs on process.exit

If a worker calls process.exit(), it terminates the entire Node.js process, not just the worker. Use parentPort.close() or simply let the worker's event loop drain naturally. For forced worker shutdown, call worker.terminate() from the main thread.

Best Practices

  • Use thread pools, not spawn-per-task. Thread creation overhead (30-50ms) adds up. A pool of os.cpus().length workers handles most workloads. Only spawn individual workers for truly long-running background tasks.

  • Set resource limits on workers. Always configure resourceLimits to prevent a single runaway worker from consuming all system memory. Set maxOldGenerationSizeMb to a reasonable value based on your expected workload.

  • Prefer message passing over shared memory. SharedArrayBuffer is powerful but introduces the full complexity of concurrent programming: race conditions, deadlocks, and memory corruption. Use it only when structured clone overhead is a proven bottleneck, and protect all shared access with Atomics.

  • Transfer large buffers instead of copying. When passing ArrayBuffer objects larger than a few kilobytes, use the transferList parameter in postMessage(). The zero-copy transfer is orders of magnitude faster than structured cloning for large data.

  • Implement task timeouts. CPU-intensive operations can sometimes run longer than expected. Always wrap worker tasks in timeouts and terminate workers that exceed the limit. Replace terminated workers automatically to maintain pool capacity.

  • Handle worker crashes gracefully. Workers can crash from uncaught exceptions, out-of-memory conditions, or segfaults in native addons. Your pool must detect dead workers and replace them without dropping queued tasks.

  • Size your pool to your CPU cores. More worker threads than CPU cores does not improve CPU-bound throughput. It actually hurts performance due to context switching overhead. Use os.cpus().length as your default, and benchmark from there.

  • Keep worker scripts focused. Each worker should do one category of work well. Do not build a single worker that handles image processing, PDF generation, and cryptographic hashing. Separate pools for separate workloads gives you independent scaling and fault isolation.

  • Profile before optimizing. Not everything needs worker threads. If an operation takes less than 10ms, the overhead of dispatching to a worker and collecting the result may exceed the computation itself. Use console.time() or perf_hooks to measure before reaching for threads.

  • Implement graceful shutdown. When your process receives SIGTERM (e.g., during deployment), drain the task queue, let in-flight tasks finish, and terminate workers cleanly. This prevents data corruption and dropped requests during rolling deployments.

References

Powered by Contentful