Offload Heavy Sorts and Transforms to a True Parallel Thread
Worker Threads for CPU-Bound Algorithms
node:worker_threads lets you run CPU-intensive algorithms on a separate OS thread, keeping the main event loop free — learn message passing, transferable buffers, and when SharedArrayBuffer is worth the complexity.
What you'll learn
- Spawn a worker thread and send work to it via postMessage
- Return results back to the main thread using the message event
- Choose between message-copy semantics and SharedArrayBuffer for large data
The event-loop chunking trick from the previous lesson still runs on one CPU
core. When you need to sort a 10-million-element array, process an image buffer,
or run a graph algorithm against a large dataset, you want a second OS thread —
and node:worker_threads provides exactly that.
Spawning a Worker
// main.mjs
import { Worker, isMainThread, parentPort, workerData }
from 'node:worker_threads';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
if (isMainThread) {
const data = Array.from({ length: 1_000_000 }, () => Math.random());
const worker = new Worker(__filename, { workerData: data });
worker.once('message', (sorted) => {
console.log('First element after sort:', sorted[0]);
});
worker.once('error', (err) => console.error(err));
} else {
// Worker thread: sort and send back
const result = workerData.slice().sort((a, b) => a - b);
parentPort.postMessage(result);
} workerData is structured-cloned when the thread starts — a full copy.
postMessage also clones by default. Cloning is safe but costs memory and CPU
proportional to data size.
Transferable Buffers (Zero-Copy)
For large binary data, transfer ownership of an ArrayBuffer instead of
copying it. The sender can no longer access it after the transfer.
// main.mjs (transfer path)
import { Worker } from 'node:worker_threads';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const buf = new ArrayBuffer(1024 * 1024 * 64); // 64 MB
const worker = new Worker(__filename, {
workerData: buf,
transferList: [buf], // transfer, not copy
});
// buf is now detached here — accessing it throws SharedArrayBuffer for Bidirectional Sharing
SharedArrayBuffer lets both threads read and write the same memory without
any message passing. Use Atomics to coordinate:
// Both threads share this buffer — no copies, no messages for data
const shared = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT * 4);
const flag = new Int32Array(shared);
// Worker signals it is done by writing 1 to index 0
Atomics.store(flag, 0, 1);
Atomics.notify(flag, 0, 1);
// Main thread waits (do NOT use Atomics.wait on the main thread in production
// — it blocks; use Atomics.waitAsync instead)
Atomics.waitAsync(flag, 0, 0).value.then(() => {
console.log('Worker finished, flag is:', Atomics.load(flag, 0));
}); Choosing a Data Passing Strategy
| Strategy | Best for | Gotcha |
|---|---|---|
workerData clone | Small startup config | Full copy on start |
postMessage clone | General messages | Copy cost per message |
Transferable ArrayBuffer | Large binary, one-way | Sender loses access |
SharedArrayBuffer | High-frequency bidirectional | Needs Atomics for safety |
Worker Pool Pattern
For a server handling many requests, spawning a worker per request is
expensive. Maintain a pool of N workers (typically os.cpus().length - 1) and
queue tasks to idle workers. Libraries like piscina implement this pattern
and are production-ready.
Up Next
When your data is too big for memory altogether, streams let you process it piece by piece without a worker thread at all.
Streams for Large Data Processing →