| |

Node.js 64 🟢 Parallel Processing and Multithreading with Worker Threads (node:worker_threads)

Node.js runs your JavaScript on a single main thread. This is by design—the event loop handles thousands of concurrent I/O operations without the overhead of thread management. But there is a category of work the event loop cannot save you from: CPU-bound computation. A tight loop calculating a cryptographic hash, parsing a massive JSON payload, or resizing an image blocks the event loop entirely. While that code runs, no other request is served. The server appears frozen.

Worker threads are Node.js’s answer to this problem. They are real operating-system threads that run JavaScript in parallel with the main thread, each with its own V8 isolate and its own event loop . They are not a replacement for async I/O—they are an escape hatch for the work that async I/O cannot handle.

This chapter covers the worker_threads module: how to spawn workers, how to communicate with them, how to avoid the hidden cost of data copying, and when not to use them at all.

Key point: Worker threads run JavaScript in parallel on separate OS threads, each with its own V8 isolate; data is copied between threads by default using the structured clone algorithm, and SharedArrayBuffer is the mechanism for true shared memory when copying is too expensive.


Why worker threads exist

The blocked event loop problem. Node.js is single-threaded because I/O is handled asynchronously by the operating system. A database query does not block the event loop—it registers a callback and moves on. But a for loop from 0 to 10 billion does block it. No callback can run until that loop finishes. Worker threads solve this by moving CPU-bound work off the main thread entirely .

The process-vs-thread distinction. Node.js has two parallelization mechanisms: child_process and worker_threads. Child processes are separate OS processes with their own memory space and their own V8 instance. Worker threads are separate threads within the same process, sharing the same memory space at the OS level. Threads are lighter to create, cheaper to communicate between, and better suited for workloads that need to pass data back and forth .

Copy-by-default safety. When you send data to a worker via postMessage, the data is copied using the structured clone algorithm . The worker gets its own deep copy. The main thread keeps its original. This means two threads cannot accidentally mutate the same object—the class of bugs that plagues shared-memory concurrency in C and Java is largely absent by default .

The cost of copying. Copy-by-default is safe, but it is not free. A 10 MB object sent to a worker is serialized, transferred, and deserialized—twice if the result comes back . For large payloads, the copying cost can exceed the computation cost. This is why SharedArrayBuffer and transferList exist: they let you avoid copying when the data is large enough that the overhead matters.

The isolation boundary. Workers do not share closures, module state, or global variables with the main thread. They load their own modules, maintain their own globals, and cannot access the main thread’s variables . This isolation is a feature—it prevents accidental coupling—but it also means you cannot simply pass a database connection or a class instance to a worker and expect it to work.


a. Spawning a worker and basic communication

The worker_threads module provides the Worker class. Creating a worker runs a JavaScript file (or a string of code) in a new thread. The parent and worker communicate through MessagePort objects: parentPort in the worker, and the Worker instance in the parent.

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

if (isMainThread) {
  const worker = new Worker(__filename);
  worker.on('message', (msg) => {
    console.log('Parent received:', msg);
  });
  worker.postMessage('Hello from parent');
} else {
  parentPort.on('message', (msg) => {
    console.log('Worker received:', msg);
    parentPort.postMessage('Hello from worker');
  });
}

The isMainThread boolean distinguishes the two execution contexts. When the file is run directly, isMainThread is true. When the same file is loaded inside a Worker, isMainThread is false, and parentPort is available .

You can also spawn a worker from a separate file:

// main.js
const { Worker } = require('node:worker_threads');
const worker = new Worker('./worker.js');

// worker.js
const { parentPort } = require('node:worker_threads');
parentPort.on('message', (msg) => {
  parentPort.postMessage(`Echo: ${msg}`);
});

The Worker constructor accepts options. workerData is a value that is cloned and made available in the worker as require('node:worker_threads').workerData . resourceLimits caps the worker’s heap size—if the worker exceeds maxOldGenerationSizeMb or maxYoungGenerationSizeMb, it is terminated . This is useful for preventing a runaway computation from consuming all available memory.


b. The structured clone algorithm and transferList

By default, postMessage uses the structured clone algorithm to copy the data . This algorithm recursively traverses the object, copying primitives, plain objects, arrays, Date, RegExp, Map, Set, ArrayBuffer, and typed arrays. It does not copy functions, class instances (methods are functions), symbols, DOM nodes, or promises . Attempting to send any of these throws a DataCloneError.

The copy semantics mean the worker’s copy is independent. Mutations in the worker do not affect the parent’s original, and vice versa. This is safe but potentially expensive.

For ArrayBuffer objects, you can avoid the copy entirely by using the transferList option. Transferring an ArrayBuffer moves ownership to the worker, and the parent’s reference becomes unusable .

const buffer = new ArrayBuffer(1024);
worker.postMessage({ buffer }, [buffer]);
// buffer is now unusable in the parent

Transfer is a move, not a copy. It is faster for large buffers, but it is not always appropriate—if the parent needs to retain the data, transfer is not an option.

For shared memory, SharedArrayBuffer allows multiple threads to read and write the same underlying memory. Changes made by one thread are visible to others . However, SharedArrayBuffer only shares binary data—you cannot share plain JavaScript objects. And once you introduce shared memory, you reintroduce the classic concurrency problems: data races, torn reads, and the need for Atomics to coordinate access .


c. Worker pools and lifecycle management

Creating a worker is not free. Each worker spins up a new V8 isolate, which costs memory and startup time . Creating a worker per request is an anti-pattern—the overhead of spawning exceeds the benefit of parallelizing the work .

The correct pattern is a worker pool: a fixed number of workers that stay alive, receiving tasks from a queue and returning results. The pool size is typically the number of CPU cores, since CPU-bound work cannot benefit from more threads than cores .

const os = require('node:os');
const numCPUs = os.cpus().length;

Node.js does not include a built-in worker pool. Libraries like piscina manage pools, or you can build a simple one by keeping workers alive and sending tasks via postMessage.

Workers must be terminated explicitly. A worker keeps running until it finishes its script or is terminated with worker.terminate() . If you forget to clean up workers, they consume memory and CPU indefinitely. A timeout on idle workers or a pool with a shutdown routine is standard practice .

Errors in workers do not propagate to the parent’s try/catch. They are emitted as 'error' events on the Worker instance . If you do not listen for 'error', the worker fails silently—or worse, the process exits. Always attach an error handler .


Complete Example Session

The following session builds a worker that performs a CPU-intensive computation, sends the result back, and is managed by a simple pool.

// ============================================
// PART 1: THE WORKER FILE (worker.js)
// ============================================
const { parentPort, workerData } = require('node:worker_threads');

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

parentPort.on('message', (task) => {
  if (task.type === 'compute') {
    const result = fibonacci(task.n);
    parentPort.postMessage({ id: task.id, result });
  }
});

// ============================================
// PART 2: THE MAIN FILE (main.js)
// ============================================
const { Worker, isMainThread, parentPort } = require('node:worker_threads');
const os = require('node:os');

if (isMainThread) {
  const POOL_SIZE = Math.min(os.cpus().length, 4);
  const workers = [];
  const taskQueue = [];
  let nextId = 1;

  // ============================================
  // PART 3: CREATE THE WORKER POOL
  // ============================================
  for (let i = 0; i < POOL_SIZE; i++) {
    const worker = new Worker('./worker.js');
    worker.busy = false;
    worker.on('message', (result) => {
      worker.busy = false;
      console.log(`Task ${result.id} result: ${result.result}`);
      dispatchNext();
    });
    worker.on('error', (err) => {
      console.error(`Worker error: ${err.message}`);
    });
    worker.on('exit', (code) => {
      if (code !== 0) console.error(`Worker exited with code ${code}`);
    });
    workers.push(worker);
  }

  // ============================================
  // PART 4: TASK DISPATCH
  // ============================================
  function dispatchNext() {
    const idle = workers.find((w) => !w.busy);
    const task = taskQueue.shift();
    if (idle && task) {
      idle.busy = true;
      idle.postMessage(task);
    }
  }

  // ============================================
  // PART 5: SUBMIT TASKS
  // ============================================
  function submit(n) {
    return new Promise((resolve) => {
      taskQueue.push({ id: nextId++, type: 'compute', n });
      dispatchNext();
    });
  }

  // ============================================
  // PART 6: RUN TASKS
  // ============================================
  async function main() {
    const results = await Promise.all([
      submit(40),
      submit(35),
      submit(38),
      submit(32),
    ]);
    console.log('All done');
  }

  main().catch(console.error);

  // ============================================
  // PART 7: GRACEFUL SHUTDOWN
  // ============================================
  process.on('SIGTERM', async () => {
    await Promise.all(workers.map((w) => w.terminate()));
    process.exit(0);
  });

  // ============================================
  // PART 8: TRANSFERRING A LARGE BUFFER
  // ============================================
  const bigBuffer = new ArrayBuffer(10 * 1024 * 1024);
  const worker = workers[0];
  worker.postMessage({ buffer: bigBuffer }, [bigBuffer]);
  // bigBuffer is now unusable in the main thread

  // ============================================
  // PART 9: SHARED MEMORY (when copying is too slow)
  // ============================================
  const { SharedArrayBuffer } = require('node:worker_threads');
  const sab = new SharedArrayBuffer(4);
  const int32 = new Int32Array(sab);
  workers[0].postMessage({ sab });
  // Worker can modify int32 directly

  // ============================================
  // PART 10: ENVIRONMENT DATA
  // ============================================
  const { setEnvironmentData } = require('node:worker_threads');
  setEnvironmentData('config', { maxRetries: 3 });
  // New workers can read this with getEnvironmentData
}

The ten parts cover the worker file, the main file with pool creation, task dispatch, submission, execution, graceful shutdown, buffer transfer, shared memory, and environment data. What the session demonstrates is that workers are not fire-and-forget: they require lifecycle management, error handling, and a strategy for data movement that avoids copying when it matters.


Quick Reference

Core API

ItemPurpose
new Worker(path)Spawn a worker
worker.postMessage(value)Send to worker
parentPort.postMessage(value)Send to parent
worker.on('message', fn)Receive from worker
parentPort.on('message', fn)Receive from parent
worker.terminate()Stop worker
isMainThreadtrue in main thread

Worker Constructor Options

OptionMeaning
workerDataCloned data available in worker
resourceLimitsHeap and stack limits
envEnvironment variables (SHARE_ENV to share)
stdout / stderrRedirect output

Structured Clone Support

TypeCloned?
Primitives, plain objects, arrays✅
Date, RegExp, Map, Set✅
ArrayBuffer, TypedArrays✅
Functions, class instances❌
Symbols, Promises, DOM nodes❌

Data Transfer Strategies

StrategyCostUse When
Structured cloneCopySmall data, safety
transferListMoveLarge ArrayBuffer
SharedArrayBufferNone (shared)Multi-worker shared state

Best Practices

✅ Do This:

worker.on('error', (err) => log(err));                    // ✅ handle errors
const pool = new WorkerPool(os.cpus().length);            // ✅ pool, not per-request
worker.postMessage(data, [buffer]);                       // ✅ transfer large buffers
await worker.terminate();                                 // ✅ clean up

❌ Don’t Do This:

new Worker('./worker.js');                                // ❌ per-request spawn
worker.postMessage(classInstance);                        // ❌ DataCloneError
// no error handler                                        // ❌ silent failures
// workers left running                                   // ❌ memory leak

Common Pitfalls

PitfallWhy It HappensFix
DataCloneErrorSending functions or class instancesSend plain data, reconstruct in worker
Worker silent failureNo 'error' listenerAlways attach error handler
Slow despite workersCopy overhead exceeds computationUse transfer or shared memory
Memory exhaustionToo many workersUse bounded pool
Worker cannot access main stateIsolated V8 isolatePass data or reconfigure in worker

Real-World Examples

1. Image resizing

const worker = new Worker('./image-worker.js');
worker.postMessage({ imagePath, width: 800 });

2. JSON parsing

parentPort.on('message', (json) => {
  parentPort.postMessage(JSON.parse(json));
});

3. Cryptographic hashing

parentPort.on('message', (data) => {
  const hash = crypto.createHash('sha256').update(data).digest('hex');
  parentPort.postMessage(hash);
});

4. Transfer buffer

worker.postMessage({ buffer }, [buffer]);

5. Shared counter

const sab = new SharedArrayBuffer(4);
const counter = new Int32Array(sab);
Atomics.add(counter, 0, 1);

6. Worker pool size

const poolSize = require('node:os').cpus().length;

7. Resource limits

new Worker('./worker.js', {
  resourceLimits: { maxOldGenerationSizeMb: 256 }
});

8. Graceful shutdown

process.on('SIGTERM', async () => {
  await Promise.all(workers.map(w => w.terminate()));
});

9. Environment data

setEnvironmentData('config', { debug: true });

10. Check if main thread

if (isMainThread) { /* spawn workers */ } else { /* worker code */ }

Visual

Worker Thread Architecture

┌──────────────────────────────────────────────┐
│  MAIN THREAD                                 │
│  Event Loop                                  │
│  ┌────────────────────────────────────────┐  │
│  │  Handles I/O, responds to requests     │  │
│  └────────────────┬───────────────────────┘  │
│                   │                          │
│         postMessage (structured clone)       │
│                   │                          │
│         ┌─────────┴─────────┐                │
│         │                   │                │
│         ▼                   ▼                │
│  ┌─────────────┐     ┌─────────────┐         │
│  │ WORKER 1    │     │ WORKER 2    │         │
│  │ V8 isolate  │     │ V8 isolate  │         │
│  │ CPU work    │     │ CPU work    │         │
│  └─────────────┘     └─────────────┘         │
│  Same process, different threads             │
└──────────────────────────────────────────────┘

Copy vs Transfer vs Shared

┌──────────────────────────────────────────────┐
│  STRUCTURED CLONE (default)                  │
│  Parent ──▶ copy ──▶ Worker                  │
│  Both have independent copies                │
│  Cost: O(size)                               │
│                                              │
│  TRANSFER (transferList)                     │
│  Parent ──▶ move ──▶ Worker                  │
│  Parent's reference becomes unusable         │
│  Cost: O(1)                                  │
│                                              │
│  SHARED MEMORY (SharedArrayBuffer)           │
│  Parent ◀──▶ same memory ◀──▶ Worker         │
│  Both read/write simultaneously              │
│  Cost: 0, but requires Atomics               │
└──────────────────────────────────────────────┘

Worker Pool

┌──────────────────────────────────────────────┐
│  TASK QUEUE                                  │
│  [T1] [T2] [T3] [T4] [T5]                    │
│         │                                    │
│         ▼                                    │
│  ┌────────────────────────────────────────┐  │
│  │  POOL (size = CPU cores)               │  │
│  │  ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐       │  │
│  │  │ W1  │ │ W2  │ │ W3  │ │ W4  │       │  │
│  │  │busy │ │idle │ │busy │ │idle │       │  │
│  │  └─────┘ └─────┘ └─────┘ └─────┘       │  │
│  └────────────────────────────────────────┘  │
│  Idle workers pick up tasks from queue       │
└──────────────────────────────────────────────┘

When to Use Worker Threads

┌──────────────────────────────────────────────┐
│  CPU-BOUND (long loop, parsing, hashing)     │
│  ✅ USE worker_threads                       │
│                                              │
│  I/O-BOUND (DB, HTTP, file read)             │
│  ❌ DON'T USE — event loop handles it        │
│                                              │
│  UNTRUSTED CODE                              │
│  ❌ DON'T USE — worker is not a sandbox      │
│  Use child process with dropped privileges   │
│                                              │
│  SHORT TASK (< 10ms)                         │
│  ❌ DON'T USE — overhead exceeds benefit     │
└──────────────────────────────────────────────┘

Summary

ItemValue
Modulenode:worker_threads
Thread modelOS threads, same process
Default data transferStructured clone (copy)
Transfer mechanismtransferList (move)
Shared memorySharedArrayBuffer + Atomics
Worker creation costV8 isolate per worker
Recommended pool sizeCPU cores
Error handling'error' event
Cleanupworker.terminate()
Not forI/O-bound work

Key takeaways:

  • Worker threads are for CPU-bound work. They move computation off the main thread so the event loop stays responsive .
  • Data is copied by default. The structured clone algorithm copies the entire payload, which can be expensive for large objects .
  • transferList moves instead of copies. For large ArrayBuffer objects, transfer avoids the serialization cost .
  • SharedArrayBuffer shares memory directly. Changes in one thread are visible in others, but you need Atomics to coordinate access .
  • Workers are isolated. They do not share closures, module state, or globals with the main thread. You must pass data explicitly .
  • Use a pool, not per-request workers. Creating a worker is expensive; a fixed pool amortizes the cost .
  • Handle errors explicitly. Worker errors do not propagate to the main thread’s try/catch—they emit 'error' events .
  • Workers are not a sandbox. They share the same process and privileges as the main thread. For untrusted code, use a separate process with dropped privileges .

Remember: Worker threads are Node.js’s mechanism for true parallel JavaScript execution. They exist because the event loop cannot handle CPU-bound work without blocking. The default copy semantics keep you safe from data races, but the copy cost can be significant. For large data, use transferList or SharedArrayBuffer. For repeated tasks, use a pool. For I/O-bound work, do not use them at all—the event loop already handles it. Worker threads are a tool for a specific problem: CPU-bound computation that would otherwise freeze your server.



Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!