Offloading CPU‑bound work in Node.js with worker_threads
Learn how to keep the Node.js event loop responsive by moving heavy calculations into a worker thread, with a concrete example and notes on limits.
16 Aug 2026, 01:08 UTC

Why use worker_threads for CPU‑bound work
Node.js runs JavaScript on a single thread that handles the event loop. When a long‑running calculation blocks that thread, incoming I/O (HTTP requests, file reads, etc.) is delayed. Moving the calculation to a worker thread keeps the main thread free to process events.
Worked example: summing numbers in a worker
Save the following as main.js and run it with node main.js (Node ≥ 12 LTS, e.g., v20.11.0).
const { Worker, isMainThread, parentPort } = require('worker_threads');
if (isMainThread) {
// Main thread: create worker, send start timestamp, wait for result
const worker = new Worker(__filename);
worker.postMessage({ start: Date.now() });
worker.on('message', msg => {
console.log(`Result: ${msg.sum} in ${Date.now() - msg.start} ms`);
// Clean up the worker to free its memory
worker.terminate();
});
// Optional: show that the event loop stays alive
setInterval(() => console.log('tick'), 1500);
} else {
// Worker thread: perform the CPU‑bound loop
parentPort.on('message', msg => {
let sum = 0;
for (let i = 0; i < 1e7; i++) sum += i;
parentPort.postMessage({ sum, start: msg.start });
});
}
When executed, the main thread prints a “tick” every 1.5 seconds while the worker computes the sum, demonstrating that the event loop is not blocked. After the worker finishes, it posts the result and the main thread logs the elapsed time before terminating the worker.
How data is transferred
Each worker has its own V8 isolate, so primitive values are copied automatically. For large binary data you can avoid copying by transferring an ArrayBuffer (or SharedArrayBuffer) as a Transferable object. Example:
// In main thread
const buf = new ArrayBuffer(1024 * 1024); // 1 MiB
worker.postMessage(buf, [buf]); // transfers ownership
// In worker
parentPort.on('message', buf => {
// buf is now an ArrayBuffer owned by the worker
});
Limits and common mistakes
- Overhead: Creating a worker consumes several megabytes of memory and adds startup latency (typically a few milliseconds). For tiny tasks the cost outweighs the benefit.
- Thread‑local work: A worker must not perform synchronous I/O that blocks its own thread; otherwise that thread becomes unavailable just like the main thread.
- Resource leaks: Forgetting to call
worker.terminate()leaves the worker alive until the process exits, wasting memory. Re‑using a terminated worker throws an error. - Debugging: Console output works, but stack traces cross thread boundaries. Use
node --inspect-brkand connect a debugger to each worker’s inspect port, or use theworker_threadsbuilt‑ingetEnvironmentDatafor debugging aids. - Wrong use case: Worker threads give no advantage for I/O‑bound work (e.g., fetching URLs, reading files) because the limiting factor is not CPU. Use the built‑in asynchronous APIs or a thread pool from
fs/promisesinstead.
Practical verification
- Run
node --versionand confirm you are on a supported LTS release (≥ 12). - Execute
node main.js. - Observe the intermittent “tick” lines printed by the
setIntervalwhile the worker is busy. - When the calculation finishes, you should see a line similar to
Result: 49999995000000 in 1230 ms, followed by no “Worker not terminated” warning. - Check memory usage with
node --inspector a process monitor; the worker’s memory should be released after theterminate()call.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.