Worker Threads for CPU Work

BackendNode.jsPerformanceArchitecture
Share on LinkedIn

A password hashing endpoint uses bcrypt with cost factor 12. Under load, response times spike from 50 ms to 8 seconds—not because bcrypt got slower, but because each hash blocks the event loop for 200 ms and queues pile up. Worker threads run JavaScript on separate V8 isolates with their own event loops, keeping the main thread free for incoming HTTP connections. They are not parallel threads in the C++ sense, but they achieve real parallelism for CPU-bound JavaScript.

Identifying event loop blocking

import { monitorEventLoopDelay } from "node:perf_hooks";

const h = monitorEventLoopDelay({ resolution: 10 });
h.enable();

setInterval(() => {
  const p99 = h.percentile(99) / 1e6;
  if (p99 > 50) console.warn(`Event loop p99: ${p99.toFixed(1)}ms`);
}, 5000);

If p99 exceeds 50 ms during CPU work, offload it.

Basic worker thread

worker.js:

import { parentPort, workerData } from "node:worker_threads";
import bcrypt from "bcrypt";

const { password, saltRounds } = workerData;
const hash = bcrypt.hashSync(password, saltRounds);
parentPort.postMessage({ hash });

main.js:

import { Worker } from "node:worker_threads";

function hashPassword(password) {
  return new Promise((resolve, reject) => {
    const worker = new Worker("./worker.js", {
      workerData: { password, saltRounds: 12 },
    });
    worker.on("message", resolve);
    worker.on("error", reject);
    worker.on("exit", (code) => {
      if (code !== 0) reject(new Error(`Worker exited with ${code}`));
    });
  });
}

Worker pool with Piscina

Spawning a new Worker per request adds 30–50 ms startup overhead. Pools reuse workers:

import Piscina from "piscina";
import { fileURLToPath } from "node:url";

const pool = new Piscina({
  filename: fileURLToPath(new URL("./hash-worker.js", import.meta.url)),
  minThreads: 2,
  maxThreads: require("node:os").cpus().length,
});

app.post("/register", async (req, res) => {
  const hash = await pool.run({
    password: req.body.password,
    saltRounds: 12,
  });
  await db.createUser({ email: req.body.email, hash });
  res.status(201).json({ ok: true });
});

Transferring large buffers

Pass ArrayBuffers without copying:

// main thread
const imageBuffer = fs.readFileSync("photo.jpg");
const result = await pool.run(imageBuffer, { transferList: [imageBuffer.buffer] });

// worker
export default function processImage(buffer) {
  // buffer is now owned by this worker — main thread cannot use it
  return resize(buffer, 800, 600);
}

transferList moves ownership. The main thread's buffer becomes detached.

SharedArrayBuffer for counters

import { Worker } from "node:worker_threads";

const sharedBuffer = new SharedArrayBuffer(4);
const counter = new Int32Array(sharedBuffer);

const workers = Array.from({ length: 4 }, () =>
  new Worker("./counter-worker.js", { workerData: { sharedBuffer } })
);

// counter-worker.js
const counter = new Int32Array(workerData.sharedBuffer);
for (let i = 0; i < 1_000_000; i++) {
  Atomics.add(counter, 0, 1);
}

Use Atomics for thread-safe operations on shared memory.

Image processing example

// resize-worker.js
import { parentPort } from "node:worker_threads";
import sharp from "sharp";

parentPort.on("message", async ({ buffer, width, height }) => {
  try {
    const resized = await sharp(buffer)
      .resize(width, height)
      .jpeg({ quality: 80 })
      .toBuffer();
    parentPort.postMessage({ ok: true, buffer: resized });
  } catch (err) {
    parentPort.postMessage({ ok: false, error: err.message });
  }
});

Sharp in a worker thread processes images without blocking HTTP handlers.

What not to offload

Error handling and timeouts

function runWithTimeout(pool, data, ms = 5000) {
  return Promise.race([
    pool.run(data),
    new Promise((_, reject) =>
      setTimeout(() => reject(new Error("Worker timeout")), ms)
    ),
  ]);
}

Workers that hang (infinite loop) do not crash the main process. Set timeouts and kill stuck workers:

const worker = new Worker("./task.js");
const timer = setTimeout(() => worker.terminate(), 10_000);
worker.on("exit", () => clearTimeout(timer));

Worker pool sizing

Size pools against CPU cores and workload type:

import os from "node:os";
import Piscina from "piscina";

const pool = new Piscina({
  filename: "./worker.js",
  maxThreads: os.availableParallelism(),  // not cpus().length on containers
  minThreads: 2,
  idleTimeout: 30_000,
});

CPU-bound tasks: maxThreads = cores. Mixed I/O + CPU: maxThreads = cores * 1.5. More threads than cores on CPU-bound work increases context switching without throughput gain.

Monitor queue depth — sustained backlog means scale workers or optimize task size.

Structured cloning costs

worker.postMessage() uses structured clone — large objects copy memory:

// BAD: sends 50 MB buffer every task
pool.run({ buffer: hugeArrayBuffer });

// GOOD: transfer ownership (zero-copy)
pool.run({ buffer: hugeArrayBuffer }, { transferList: [hugeArrayBuffer] });

SharedArrayBuffer enables true sharing but requires cross-origin isolation headers in browsers — fine in Node, rare in web workers.

When to use child processes instead

Need worker_threads child_process
Shared memory Yes (limited) No
Crash isolation Whole process affected Child crash isolated
Different V8 isolate Yes Yes
Run non-Node code No Yes (Python, etc.)

Use child_process.fork for untrusted user code — a segfault in native addon shouldn't kill the HTTP server.

Pair with Node streams backpressure when piping large data through worker pipelines.

Common production mistakes

Teams get node worker threads cpu wrong in predictable ways:

Production implementations of node worker threads cpu fail when staging mirrors production topology poorly, rollback is untested, and on-call runbooks describe the happy path only.

Resources

Frequently asked questions

When should I use worker threads vs child processes?

Use worker threads for CPU-bound tasks that need shared memory or frequent communication—image resizing, JSON parsing of large payloads, bcrypt hashing. Use child processes for isolation (crash one without killing the server), running non-Node code, or when memory leaks in untrusted code are a concern.

How many worker threads should I create?

Match CPU core count for CPU-bound work—creating more threads than cores adds context-switching overhead without throughput gain. For mixed I/O and CPU workloads, cores minus one (leaving one for the main event loop) is a reasonable starting point.

Can worker threads access the same variables as the main thread?

Not directly—they have separate V8 isolates. Share data via message passing (postMessage), SharedArrayBuffer for typed arrays, or transfer ArrayBuffers with zero-copy. Treat workers like separate programs that exchange messages.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →