Guide
Offload CPU‑Intensive Work with Node.js worker_threads
Learn how to move heavy calculations to a worker thread so the Node.js event loop stays responsive for I/O, with a complete example and verification steps.
Published by Tasadduq Burney
12 Nov 2025, 19:10 UTC
4 min147.9K views0

Offload CPU‑Intensive Work with Node.js worker_threads
When a Node.js application must perform a heavy calculation (e.g., large‑number Fibonacci, image processing, cryptographic hashing) the event loop can become blocked, delaying I/O callbacks and making the server unresponsive. Moving that work to a worker thread keeps the main thread free to handle sockets, timers, and other asynchronous tasks.
Desired outcome
Run a CPU‑bound function in a separate thread while the main thread continues to process I/O events without noticeable delay.
Prerequisites
- Node.js version 12 or later (worker_threads is stable from v12).
- Basic familiarity with ES6 modules or CommonJS.
- A synchronous function that represents the work you want to offload.
Procedure
- Create the worker file (
worker.js) that receives data viaparentPort, performs the computation, and sends the result back. - In the main script (
main.js), import theworker_threadsmodule, instantiate aWorkerpointing toworker.js, post the input data, listen formessageanderrorevents, and terminate the worker when the result is received. - Keep the main thread responsive by running a periodic timer (e.g.,
setInterval) that logs a tick or updates a UI.
// worker.js
const { parentPort } = require('worker_threads');
function heavyCompute(n) {
// Example: iterative Fibonacci (CPU‑intensive for large n)
let a = 0, b = 1;
for (let i = 0; i < n; i++) {
const c = a + b;
a = b;
b = c;
}
return a;
}
// Expect a single numeric argument
parentPort.on('message', (msg) => {
if (typeof msg !== 'number' || !Number.isInteger(msg) || msg < 0) {
parentPort.postMessage({ err: 'Invalid input' });
return;
}
try {
const result = heavyCompute(msg);
parentPort.postMessage({ result });
} catch (e) {
parentPort.postMessage({ err: e.message });
}
});
// main.js
const { Worker } = require('worker_threads');
const path = require('path');
function runWorker(input) {
return new Promise((resolve, reject) => {
const worker = new Worker(path.resolve(__dirname, 'worker.js'));
let terminated = false;
const cleanup = () => {
if (!terminated) {
terminated = true;
worker.terminate();
}
};
worker.on('message', (msg) => {
if (msg.err) {
reject(new Error(msg.err));
} else {
resolve(msg.result);
}
cleanup();
});
worker.on('error', (err) => {
reject(err);
cleanup();
});
worker.on('exit', (code) => {
if (!terminated && code !== 0) {
reject(new Error(`Worker stopped with exit code ${code}`));
}
cleanup();
});
worker.postMessage(input);
});
}
// Example usage: keep main thread responsive while worker runs
(async () => {
const workInput = 450000; // large enough to take noticeable time
console.log('Starting heavy computation…');
// Timer to show the event loop is still alive
const tick = setInterval(() => console.log('tick'), 100);
try {
const result = await runWorker(workInput);
clearInterval(tick);
console.log('Result:', result);
} catch (e) {
clearInterval(tick);
console.error('Worker failed:', e.message);
// Fallback: compute synchronously (optional)
// const fallback = heavyCompute(workInput);
// console.log('Fallback result:', fallback);
}
})();
Expected checks
- While the worker is running, the
tickmessages should appear every ~100 ms with minimal delay (a few milliseconds at most). - The value printed as
Result:must match the output of the sameheavyComputefunction executed synchronously on the main thread. - No uncaught exceptions should appear; any worker error is caught by the
errorlistener and reported.
Recovery options
- If the
errorevent fires, log the error and decide whether to retry the task with a new worker or fall back to a synchronous execution. - If the worker exits with a non‑zero code, treat it as a failure and apply the same fallback logic.
- Ensure that the worker is always terminated (
worker.terminate()) in the cleanup handler to avoid leaking resources.
Limitations and practical verification
- Each worker runs in its own V8 isolate, consuming separate memory (~a few MBs). Avoid creating hundreds of workers for short‑lived tasks; instead, reuse a pool or limit concurrency to the number of CPU cores.
- Data transferred between threads uses the structured clone algorithm. Objects containing functions, prototypes, or non‑cloneable types (e.g.,
Bufferis cloneable, butMapwith custom keys may lose behavior) must be serialized manually or avoided. - To verify that the event loop stays responsive, measure latency with
process.hrtime.bigint()before and after asetTimeoutcallback while the worker is active; the delta should remain under a few milliseconds. - Practical check: run the script with
node main.jsand observe the tick output. Then compute the same Fibonacci number synchronously (e.g.,console.log(heavyCompute(workInput))) and compare the numbers; they must be identical.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.