JavaScript Basics: Exchange Data with Worker Threads
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 1The tested runtime prints:
v24.19.01. 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.mjsOutput:
sum of squares: 55The 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.mjsOutput:
sender bytes after transfer: 0
worker total: 60The 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.mjsOutput:
failure: values must be finite numbers
worker exit: 1This 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_dirAcknowledgements
This post was created with assistance from AI.