Skip to content
JavaScript Basics: Exchange Data with Worker Threads

JavaScript Basics: Exchange Data with Worker Threads

October 23, 2026

A worker thread runs JavaScript separately from the main thread. It can keep substantial CPU work away from the main event loop, but starting a worker and moving data also cost time. These tiny examples teach the communication model, not a performance optimization for small arrays.

These three independent examples were executed on Linux with Node.js v24.19.0. Use Node.js 24 for this tutorial; only the installed version above was tested. They use built-in modules, make no network requests, and assert their results. The output below is exact, not a sketch.

Prepare a scratch directory

You need Bash, Node.js on your PATH, a text editor, and the mktemp, rm, and rmdir utilities. Keep the same terminal open and save each named file as UTF-8 in the new directory. The .mjs extension selects an ES module.

node --version
start_dir="$PWD"
work_dir="$(mktemp -d)" || exit 1
cd -- "$work_dir" || exit 1

The tested runtime prints:

v24.19.0

1. Run a calculation and wait for worker completion

Save this as calculate-in-worker.mjs:

import assert from 'node:assert/strict';
import { Worker, isMainThread, parentPort, workerData } from 'node:worker_threads';

if (isMainThread) {
  const worker = new Worker(new URL(import.meta.url), {
    workerData: [1, 2, 3, 4, 5]
  });
  let result;
  worker.once('message', value => { result = value; });
  await new Promise((resolve, reject) => {
    worker.once('error', reject);
    worker.once('exit', code => {
      if (code === 0) resolve();
      else reject(new Error(`worker exit ${code}`));
    });
  });
  assert.equal(result, 55);
  console.log(`sum of squares: ${result}`);
} else {
  parentPort.postMessage(workerData.reduce((sum, n) => sum + n * n, 0));
}

Run it:

node calculate-in-worker.mjs

Output:

sum of squares: 55

The same file serves as the main program and the worker; isMainThread selects the branch. workerData supplies the input, and parentPort sends the answer back. The main branch installs listeners immediately and checks the exit code before printing. Worker messages are delivered before its exit event. No message listener is left open inside the worker, so it finishes naturally. See Worker events.

2. Transfer a dedicated byte buffer

Save this as transfer-worker-buffer.mjs:

import assert from 'node:assert/strict';
import { Worker, isMainThread, parentPort, workerData } from 'node:worker_threads';

if (isMainThread) {
  const bytes = new Uint8Array([10, 20, 30]);
  const worker = new Worker(new URL(import.meta.url), {
    workerData: bytes, transferList: [bytes.buffer]
  });
  assert.equal(bytes.byteLength, 0);
  let total;
  worker.once('message', value => { total = value; });
  await new Promise((resolve, reject) => {
    worker.once('error', reject);
    worker.once('exit', code => {
      if (code === 0) resolve();
      else reject(new Error(`worker exit ${code}`));
    });
  });
  assert.equal(total, 60);
  console.log(`sender bytes after transfer: ${bytes.byteLength}`);
  console.log(`worker total: ${total}`);
} else {
  parentPort.postMessage(workerData.reduce((sum, byte) => sum + byte, 0));
}

Run it:

node transfer-worker-buffer.mjs

Output:

sender bytes after transfer: 0
worker total: 60

The transfer list moves this ArrayBuffer to the worker instead of copying its bytes. The original view becomes detached, which explains its zero byteLength. Every other view of that same buffer would also lose access. Use a dedicated ArrayBuffer when transfer ownership is intentional; do not assume pooled Node Buffers behave like this allocation. This is transfer, not shared memory. See transfer considerations.

3. Observe a failed worker without crashing the parent

Save this as handle-worker-validation-error.mjs:

import assert from 'node:assert/strict';
import { Worker, isMainThread, parentPort, workerData } from 'node:worker_threads';

if (isMainThread) {
  const worker = new Worker(new URL(import.meta.url), {
    workerData: [4, 'bad', 9]
  });
  let failure;
  const code = await new Promise(resolve => {
    worker.once('error', error => { failure = error; });
    worker.once('exit', resolve);
  });
  assert.ok(failure);
  assert.equal(failure.message, 'values must be finite numbers');
  assert.equal(code, 1);
  console.log(`failure: ${failure.message}`);
  console.log(`worker exit: ${code}`);
} else {
  if (!workerData.every(Number.isFinite)) {
    throw new Error('values must be finite numbers');
  }
  parentPort.postMessage(workerData.length);
}

Run it:

node handle-worker-validation-error.mjs

Output:

failure: values must be finite numbers
worker exit: 1

This deliberately invalid input causes an uncaught worker exception. The main thread handles error, then waits for exit and verifies both. A successful parent exit here means the test handled an expected failure, not that the worker succeeded. For ordinary validation failures in a long-lived worker, sending a structured error response may be preferable to terminating it. See the error event.

Practical limits

Use a bounded worker pool for repeated expensive jobs rather than creating a thread for every tiny operation. Async I/O already works without dedicating a JavaScript worker to each request. Messages use structured cloning, not arbitrary shared JavaScript objects; functions cannot be sent as data. Workers are not a security sandbox for untrusted code, and a worker memory limit does not eliminate all process-wide failure risks.

Clean up

In the same terminal, remove only the three named source files, return to your starting directory, and remove the now-empty scratch directory:

rm -- calculate-in-worker.mjs transfer-worker-buffer.mjs handle-worker-validation-error.mjs
cd -- "$start_dir" || exit 1
rmdir -- "$work_dir"
unset start_dir work_dir

Acknowledgements

This post was created with assistance from AI.

Last updated on