Worker Threads in Node.js: Running CPU-Heavy Work in Parallel
Node.js runs your JavaScript on a single thread. That's fine for I/O, but a CPU-heavy task (resizing images, parsing a huge file, heavy maths) blocks the event loop, and every other request waits.
Spot the problem
app.get('/report', (req, res) => {
const result = crunchNumbers(bigDataset); // takes 3 seconds: the whole server freezes
res.json(result);
});
Move the work to a worker
// crunch-worker.js
import { parentPort, workerData } from 'node:worker_threads';
import { crunchNumbers } from './crunch.js';
parentPort.postMessage(crunchNumbers(workerData));
// server.js
import { Worker } from 'node:worker_threads';
function runCrunch(data) {
return new Promise((resolve, reject) => {
const worker = new Worker(new URL('./crunch-worker.js', import.meta.url), { workerData: data });
worker.once('message', resolve);
worker.once('error', reject);
worker.once('exit', (code) => code !== 0 && reject(new Error(`Worker exited with code ${code}`)));
});
}
app.get('/report', async (req, res) => {
res.json(await runCrunch(bigDataset)); // the event loop stays free
});
Use a pool in production
Starting a worker has a cost. A pool keeps a fixed number of workers (often one per CPU core) and queues tasks. The piscina package is a popular, well-maintained pool:
import Piscina from 'piscina';
const pool = new Piscina({ filename: new URL('./crunch-task.js', import.meta.url).href });
const result = await pool.run(bigDataset);
When not to use workers
- For I/O (database calls, HTTP requests): async code already handles that without blocking.
- For tiny tasks: copying data to a worker can cost more than the work itself.
- To scale across machines: use multiple processes or containers behind a load balancer.
Use SharedArrayBuffer or transferable objects (like ArrayBuffer) to avoid copying large binary data between threads.
Was this article helpful?
Comments
No comments yet. Have a question or a tip? Be the first to share it.