Node.js runs your JavaScript on a single thread. That’s fine for I/O-heavy work like reading files or querying a database, because those operations hand off to the OS and Node just waits for a callback. But the moment you run something CPU-bound — image resizing, PDF generation, hashing, heavy JSON parsing, or a data transformation over a large array — that work blocks the event loop. Every other request your server is handling stalls until it finishes.
The worker_threads module, built into Node since v10.5 (stable since v12), solves this by letting you run JavaScript in a real OS thread with its own V8 instance and event loop. This post walks through when to reach for worker threads, how to set one up correctly, and the pitfalls that trip people up in production.
Why setImmediate and async tricks don’t help
A common instinct is to break a CPU-heavy loop into chunks using setImmediate or process.nextTick, hoping to “yield” to the event loop between chunks. This does let other callbacks run between chunks, but it doesn’t make the work faster or use another CPU core — it just interleaves the blocking work with everything else, which can actually make total throughput worse under load. If the task is genuinely CPU-bound, you need a separate thread (or process), not a smarter loop.
A basic worker thread example
Here’s a minimal setup: a main thread that dispatches a CPU-heavy hashing job to a worker, and the worker that does the actual work.
// worker.js
const { parentPort, workerData } = require('worker_threads');
const crypto = require('crypto');
function hashPassword(password, iterations) {
let hash = password;
for (let i = 0; i < iterations; i++) {
hash = crypto.createHash('sha256').update(hash).digest('hex');
}
return hash;
}
const result = hashPassword(workerData.password, workerData.iterations);
parentPort.postMessage(result);
// main.js
const { Worker } = require('worker_threads');
function runWorker(password, iterations) {
return new Promise((resolve, reject) => {
const worker = new Worker('./worker.js', {
workerData: { password, iterations },
});
worker.on('message', resolve);
worker.on('error', reject);
worker.on('exit', (code) => {
if (code !== 0) {
reject(new Error(`Worker stopped with exit code ${code}`));
}
});
});
}
runWorker('correct horse battery staple', 100000).then((hash) => {
console.log('Hash:', hash);
});
While that worker is grinding through 100,000 hash iterations, your main thread’s event loop is completely free to keep handling other requests. The workerData option passes initial data into the worker at creation time; postMessage/on('message') handle ongoing communication after that.
Worker pools: don’t spawn a worker per request
Creating a new Worker has real overhead — it spins up a new V8 isolate and event loop, which typically costs several milliseconds and a non-trivial amount of memory. If your endpoint gets hit frequently, spawning a fresh worker per request will hurt you more than it helps. The standard fix is a worker pool: a fixed number of long-lived workers that pick up tasks from a shared queue.
You can build a simple pool by hand, but for production use it’s usually not worth reinventing this. Libraries like piscina or workerpool handle pool sizing, task queuing, and backpressure for you:
const Piscina = require('piscina');
const pool = new Piscina({
filename: require.resolve('./worker.js'),
maxThreads: 4,
});
app.post('/hash', async (req, res) => {
const hash = await pool.run({
password: req.body.password,
iterations: 100000,
});
res.json({ hash });
});
Choosing a pool size
A worker thread only helps throughput if there’s an idle CPU core to run it on. Spawning more workers than you have logical cores just adds context-switching overhead without more parallel capacity. A reasonable starting point is os.cpus().length - 1, leaving one core for the main thread and OS scheduling. Measure under realistic load rather than guessing — the right number depends on how CPU-heavy your tasks actually are relative to your I/O-bound request volume.
Sharing memory with SharedArrayBuffer
By default, data passed via postMessage is structured-cloned — copied, not shared. For large payloads (say, a big typed array of image pixel data), that copy cost can eat into the gains you got from parallelizing. For these cases, SharedArrayBuffer lets the main thread and workers read and write the same underlying memory without copying:
const { Worker } = require('worker_threads');
const sharedBuffer = new SharedArrayBuffer(1024 * 1024);
const sharedArray = new Uint8Array(sharedBuffer);
const worker = new Worker('./image-worker.js', {
workerData: { buffer: sharedBuffer },
});
Because the memory is genuinely shared, you’re responsible for coordinating access — typically with Atomics — to avoid race conditions between the main thread and worker writing to overlapping regions at the same time.
When worker threads are the wrong tool
Worker threads solve CPU-bound blocking, not I/O-bound scaling. If your bottleneck is waiting on a database, an external API, or disk I/O, adding worker threads won’t help — that work is already non-blocking in Node’s async model, and threads just add overhead. Similarly, if a task genuinely needs more memory or process isolation than threads provide (a crash in one worker can be isolated, but they still share the same Node binary and native addons), a child process via child_process.fork() might be a better fit despite the higher overhead per unit of isolation.
Conclusion
Node’s single-threaded model is a feature for I/O-bound work, not a limitation to route around for everything. But when you genuinely have CPU-bound work — hashing, compression, image processing, data crunching — worker threads let you use the CPU cores you already have instead of stalling every other request on your server. Start with a worker pool library rather than hand-rolling one, size your pool to your actual core count, and reach for SharedArrayBuffer only once copy overhead is a measured problem, not a theoretical one.