← Back
April 4, 2026NodeJS7 min read
Node.js worker threads let you run CPU-heavy tasks without blocking the event loop
Published April 4, 20267 min read
I had a Node.js API that generated PDF reports and the event loop would freeze for 3-4 seconds on each request. The HTTP server became unresponsive — other requests queued up. Worker threads fixed this by moving the CPU work to a separate thread while the event loop stayed free to handle other requests. Here is the complete pattern.
Why Node.js blocks on CPU work
Node.js uses a single thread for JavaScript execution. The event loop processes I/O callbacks, timers, and your code. When synchronous CPU-heavy code runs, it holds the thread — nothing else can execute until it finishes.
Workers solve this by running JavaScript in separate threads with their own event loops, communicating via message passing.
Simple worker setup
typescript
// worker.ts — runs in a separate thread
import { workerData, parentPort } from 'worker_threads';
// This is the CPU-heavy work
function generateReport(data: ReportData): Buffer {
// ... expensive PDF generation
return pdfBuffer;
}
const result = generateReport(workerData);
parentPort!.postMessage({ result });
// main.ts — creates workers from the main thread
import { Worker } from 'worker_threads';
import path from 'path';
function runWorker(
workerFile: string,
data: unknown
): Promise {
return new Promise((resolve, reject) => {
const worker = new Worker(path.resolve(workerFile), {
workerData: data,
});
worker.on('message', resolve);
worker.on('error', reject);
worker.on('exit', (code) => {
if (code !== 0) {
reject(new Error(`Worker exited with code ${code}`));
}
});
});
}
// Usage in an Express/Fastify handler
app.post('/reports', async (req, res) => {
// This does NOT block the event loop — returns immediately
const pdfBuffer = await runWorker(
'./workers/report-worker.js',
req.body
);
res.setHeader('Content-Type', 'application/pdf');
res.send(pdfBuffer);
});
Worker pool for repeated tasks
typescript
import { Worker } from 'worker_threads';
import path from 'path';
interface WorkerTask {
resolve: (value: T) => void;
reject: (reason: unknown) => void;
data: unknown;
}
class WorkerPool {
private workers: Worker[] = [];
private freeWorkers: Worker[] = [];
private queue: WorkerTask[] = [];
constructor(
private workerFile: string,
private size: number = 4,
) {
for (let i = 0; i < size; i++) {
this.addWorker();
}
}
private addWorker() {
const worker = new Worker(path.resolve(this.workerFile));
worker.on('message', (result: T) => {
const task = this.currentTasks.get(worker);
if (task) {
this.currentTasks.delete(worker);
task.resolve(result);
}
// Take next task from queue
if (this.queue.length > 0) {
const next = this.queue.shift()!;
this.runTask(worker, next);
} else {
this.freeWorkers.push(worker);
}
});
worker.on('error', (err) => {
const task = this.currentTasks.get(worker);
if (task) task.reject(err);
});
this.workers.push(worker);
this.freeWorkers.push(worker);
}
private currentTasks = new Map>();
private runTask(worker: Worker, task: WorkerTask) {
this.currentTasks.set(worker, task);
worker.postMessage(task.data);
}
run(data: unknown): Promise {
return new Promise((resolve, reject) => {
const task = { resolve, reject, data };
if (this.freeWorkers.length > 0) {
const worker = this.freeWorkers.pop()!;
this.runTask(worker, task);
} else {
this.queue.push(task);
}
});
}
async terminate() {
await Promise.all(this.workers.map(w => w.terminate()));
}
}
// Create a pool of 4 report workers at startup
const reportPool = new WorkerPool('./workers/report-worker.js', 4);
// Concurrent PDF generation — all handled by the pool
const reports = await Promise.all([
reportPool.run(report1Data),
reportPool.run(report2Data),
reportPool.run(report3Data),
]);
Sharing memory with SharedArrayBuffer
typescript
// For high-performance use cases, share memory between threads
// instead of copying data via postMessage
// main.ts
const sharedBuffer = new SharedArrayBuffer(4 * 1024 * 1024); // 4MB
const sharedArray = new Float64Array(sharedBuffer);
// Fill with data in the main thread
for (let i = 0; i < sharedArray.length; i++) {
sharedArray[i] = Math.random();
}
// Pass the buffer reference (not a copy) to the worker
const worker = new Worker('./compute-worker.js', {
workerData: { buffer: sharedBuffer, length: sharedArray.length }
});
// compute-worker.ts
import { workerData, parentPort } from 'worker_threads';
const array = new Float64Array(workerData.buffer);
// Both threads see the same memory — no data copying
let sum = 0;
for (let i = 0; i < workerData.length; i++) {
sum += array[i];
}
parentPort!.postMessage({ sum });
When to use workers vs child_process
- Workers: CPU-bound JavaScript code you want to run in parallel. Lower overhead, shared memory possible.
- child_process: External processes (shell commands, other languages). Isolated, more memory.
- Neither: I/O-bound work (database queries, HTTP calls) — Node.js handles these asynchronously on the same thread just fine. Workers only help for CPU work.
Share this
← All Posts7 min read