πŸš€AdvancedNode.js Concurrency

Worker Threads

Run CPU-intensive JavaScript in parallel using Node.js Worker Threads and shared memory

Overview

Worker Threads were introduced in Node.js 10 (stabilized in 12) to bring true multi-threading to JavaScript without forking a new process. Unlike child processes, worker threads share the same memory space as the main thread β€” which means significantly lower startup cost and the ability to share large data structures via SharedArrayBuffer without copying. The primary use case is CPU-bound computation. If you have a Node.js server that needs to compute a cryptographic hash, resize an image, parse a complex document, or run a machine learning inference, putting that work on the main thread will freeze your event loop for all other requests. Moving it to a worker thread lets the main thread continue servicing requests while the worker runs the heavy computation on a separate OS thread. Communication between the main thread and workers uses the `postMessage` / `on("message")` API, which serializes data using the Structured Clone Algorithm β€” similar to JSON but supporting more types (Maps, Sets, Dates, ArrayBuffers). Transferable objects (ArrayBuffers, MessagePorts) can be moved to another thread instead of copied, enabling zero-copy data transfer for large buffers. For truly shared state between threads, `SharedArrayBuffer` lets multiple threads read from and write to the same memory. However, this requires careful coordination using `Atomics` β€” the atomic operations API that provides memory fence and compare-and-swap primitives. Without Atomics, concurrent writes to SharedArrayBuffer can produce data races, just like in any multi-threaded language.

Key Points

  • β€’Worker threads run in the same process but on separate OS threads β€” lower startup cost than child processes
  • β€’Use workerData to pass initial data to a worker at creation time (read-only after creation)
  • β€’parentPort.postMessage() and worker.postMessage() exchange data via the Structured Clone Algorithm
  • β€’ArrayBuffers can be transferred (moved, not copied) for zero-copy communication with the transfer list
  • β€’SharedArrayBuffer enables shared memory between threads; use Atomics for safe concurrent access
  • β€’Worker threads are ideal for CPU work; child processes are better for running other executables or isolating crashes

Examples

Basic worker thread β€” offloading CPU work from the main thread

// main.js
const { Worker, isMainThread, parentPort, workerData } = require('worker_threads');
const path = require('path');

if (isMainThread) {
  // --- Main thread ---
  const worker = new Worker(__filename, {
    workerData: { start: 1, end: 50_000_000 },
  });

  worker.on('message', (result) => {
    console.log('Sum:', result); // result from worker
  });

  worker.on('error', (err) => console.error('Worker error:', err));
  worker.on('exit', (code) => console.log('Worker exited with code', code));

  console.log('Main thread continues while worker computes...');

} else {
  // --- Worker thread ---
  const { start, end } = workerData;
  let sum = 0;
  for (let i = start; i <= end; i++) sum += i;
  parentPort.postMessage(sum);
}

isMainThread lets you put worker code in the same file. In production, separate workers into their own files for clarity.

Transferring an ArrayBuffer for zero-copy data passing

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

if (isMainThread) {
  // Allocate a large buffer in the main thread
  const buffer = new ArrayBuffer(1024 * 1024); // 1 MB
  const view   = new Uint8Array(buffer);
  view.fill(42); // fill with test data

  console.log('Buffer length before transfer:', buffer.byteLength); // 1048576

  const worker = new Worker(__filename, {
    workerData: { buffer },
    transferList: [buffer], // TRANSFER ownership β€” no copy made
  });

  // buffer is now detached β€” byteLength is 0
  console.log('Buffer length after transfer:', buffer.byteLength); // 0

  worker.on('message', (result) => console.log('Sum in worker:', result));

} else {
  const view = new Uint8Array(workerData.buffer);
  const sum  = view.reduce((acc, v) => acc + v, 0);
  parentPort.postMessage(sum);
}

After transferring, the original ArrayBuffer in the main thread becomes detached (byteLength === 0). The worker now owns the memory.

SharedArrayBuffer with Atomics for thread-safe counter

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

if (isMainThread) {
  // Shared memory β€” both main and worker threads can read/write
  const sharedBuffer = new SharedArrayBuffer(4); // 4 bytes = one Int32
  const counter      = new Int32Array(sharedBuffer);

  const workers = Array.from({ length: 4 }, () =>
    new Worker(__filename, { workerData: { sharedBuffer, increments: 250_000 } })
  );

  let done = 0;
  for (const w of workers) {
    w.on('exit', () => {
      if (++done === workers.length) {
        // All workers done β€” counter should be 4 * 250_000 = 1_000_000
        console.log('Final counter:', Atomics.load(counter, 0));
      }
    });
  }

} else {
  const counter = new Int32Array(workerData.sharedBuffer);
  for (let i = 0; i < workerData.increments; i++) {
    Atomics.add(counter, 0, 1); // atomic increment β€” thread-safe
  }
}

Without Atomics.add(), concurrent counter++ would be a data race and produce incorrect results.

Common Mistakes

  • Using regular objects in the transfer list β€” only ArrayBuffer and MessagePort can be transferred; other types throw a DataCloneError
  • Writing to SharedArrayBuffer without Atomics β€” non-atomic reads and writes on shared memory produce data races and undefined behavior
  • Creating thousands of workers β€” worker threads have overhead; pool them and reuse them rather than creating one per task
  • Using Worker Threads for I/O-heavy work β€” the event loop already handles I/O non-blocking; worker threads only help with CPU-bound computation

Note

Build a worker pool instead of spawning a new Worker per task. Initialize N workers at startup, maintain a queue of pending tasks, and dispatch tasks to idle workers. This amortizes the startup cost over many tasks and bounds the number of OS threads created.

Exercises

Write Node.js code in your local terminal. Use the editor below to follow along, then mark each exercise as done once it works.

Loading editor…

Completed: 0 / 6 exercises

Knowledge Check

Question 1 of 4

What is the primary advantage of Worker Threads over Child Processes for CPU-bound work?