Understanding Worker Pools in Node.js
When working with Node.js, one of its biggest strengths is the event-driven, non-blocking I/O model. This makes Node.js fantastic for handling lots of concurrent network requests. However, things get tricky when we deal with CPU-intensive tasks (like image processing, cryptography, or video encoding). These can block the event loop and slow down the entire application.
This is where worker threads and worker pools come in.
What Are Worker Threads?
By default, Node.js runs in a single thread (the main event loop). To run CPU-heavy tasks in parallel without blocking the main loop, Node.js introduced the worker_threads module.
A worker thread runs JavaScript in a separate thread.
You can communicate with workers via message passing.
Perfect for offloading heavy computations.
Example worker:
// worker.js
const { parentPort } = require("worker_threads");
parentPort.on("message", (job) => {
// simulate heavy work
const result = job.data * 2;
parentPort.postMessage({ id: job.id, result });
});The Need for a Worker Pool
If you spawn a new worker for every task, it quickly becomes inefficient:
Worker startup time is expensive.
Too many workers can overwhelm the CPU.
Instead, we use a worker pool:
A fixed number of workers (e.g. 4).
A job queue where tasks wait until a worker is free.
Tasks are assigned to workers as they become available.
This way, you maximize concurrency without overwhelming resources.
Manual Worker Pool Implementation
Here’s a simple worker pool using worker_threads:
// pool.js
const { Worker } = require("worker_threads");
class WorkerPool {
constructor(workerPath, maxWorkers) {
this.workerPath = workerPath;
this.maxWorkers = maxWorkers;
this.workers = [];
this.idleWorkers = [];
this.queue = [];
this.jobId = 0;
for (let i = 0; i < maxWorkers; i++) {
this.addNewWorker();
}
}
addNewWorker() {
const worker = new Worker(this.workerPath);
worker.on("message", (msg) => {
const { resolve } = worker.currentJob;
resolve(msg);
worker.currentJob = null;
this.idleWorkers.push(worker);
this.runNext();
});
this.idleWorkers.push(worker);
this.workers.push(worker);
}
runNext() {
if (this.queue.length === 0 || this.idleWorkers.length === 0) return;
const { job, resolve, reject } = this.queue.shift();
const worker = this.idleWorkers.pop();
worker.currentJob = { resolve, reject };
worker.postMessage(job);
}
runJob(data) {
return new Promise((resolve, reject) => {
const job = { id: ++this.jobId, data };
this.queue.push({ job, resolve, reject });
this.runNext();
});
}
close() {
this.workers.forEach((w) => w.terminate());
}
}
module.exports = WorkerPool;Using it:
const path = require("path");
const WorkerPool = require("./pool");
(async () => {
const pool = new WorkerPool(path.resolve(__dirname, "worker.js"), 3);
const jobs = [1, 2, 3, 4, 5, 6].map((num) => pool.runJob(num));
const results = await Promise.all(jobs);
console.log("Results:", results);
pool.close();
})();Supporting Different Job Types
You can also make workers handle different types of jobs:
// worker.js
const { parentPort } = require("worker_threads");
parentPort.on("message", (job) => {
let result;
switch (job.type) {
case "double":
result = job.payload * 2;
break;
case "square":
result = job.payload ** 2;
break;
default:
result = "Unknown job type";
}
parentPort.postMessage({ id: job.id, result });
});Now you can push different jobs to the same worker pool.
Using the workerpool Module
While writing your own worker pool is a good learning exercise, in production it’s better to use a library. workerpool makes things much easier:
Example: Crypto Worker
// cryptoWorker.js
const workerpool = require('workerpool');
const crypto = require('crypto');
function hashString(str) {
return crypto.createHash('sha256').update(str).digest('hex');
}
workerpool.worker({ hashString });Example: Main File
const workerpool = require('workerpool');
const pool = workerpool.pool(__dirname + '/cryptoWorker.js', { maxWorkers: 2 });
(async () => {
const jobs = [
pool.exec('hashString', ['hello']),
pool.exec('hashString', ['world']),
];
console.log(await Promise.all(jobs));
pool.terminate();
})();With just a few lines, you get a managed worker pool with queueing and max worker limits.
Real-World Use Cases
Image processing (resizing, compression)
Video encoding
Cryptographic operations (hashing, encryption)
Machine learning inference
Data transformation
Key Takeaways
Use worker threads to offload CPU-heavy tasks in Node.js.
Always use a worker pool to avoid overhead and CPU thrashing.
For production, libraries like workerpool or BullMQ (for distributed queues) are recommended.
Worker pools let you scale Node.js apps beyond I/O, handling compute-intensive workloads efficiently.
