Back to blog

Node.js Worker Threads: How to Run CPU-Intensive Tasks Without Blocking the Event Loop

Node.js is famous for its fast, non-blocking I/O performance. This speed is achieved because Node.js executes JavaScript code inside a single main thread using an Event Loop.

When your application handles I/O tasks (like reading database rows, writing files, or requesting external APIs), Node.js offloads the tasks to the operating system kernel and continues processing incoming requests.

However, this single-threaded design has a major weakness: CPU-intensive tasks. If you run heavy mathematical algorithms, compress images, or hash large payloads on the main thread, you block the event loop, causing all incoming requests to time out.

To solve this, Node.js introduced the Worker Threads module. In this guide, we will analyze why CPU-heavy tasks block Node.js, explore Worker Threads architecture, and build a multi-threaded calculation service.

The Problem: Blocking the Event Loop

Consider a standard Node.js Express server with a slow CPU-bound endpoint:

import express from 'express';
const app = express();

function calculateFibonacci(n: number): number {
  if (n <= 1) return n;
  return calculateFibonacci(n - 1) + calculateFibonacci(n - 2);
}

app.get('/heavy', (req, res) => {
  // Heavy computation blocks the main thread
  const result = calculateFibonacci(45);
  res.send({ result });
});

app.get('/health', (req, res) => {
  // If '/heavy' is running, this request is blocked and times out!
  res.send({ status: 'OK' });
});

When /heavy is called, the CPU begins calculating the Fibonacci sequence. Because Node.js is single-threaded, it cannot process any other events. If another user attempts to fetch /health, their request is queued and delayed until the math finishes.

The Solution: Worker Threads

The worker_threads module allows you to run multiple JavaScript execution threads in parallel.

Unlike the child_process module (which spawns separate operating system processes with independent memory pools), Worker Threads run inside the same system process. Each worker thread has its own isolated V8 engine instance and event loop, but they can share memory space (using SharedArrayBuffer), making communication fast.

Step 1: Create the Worker Script

First, create a separate script to handle the CPU-intensive calculation. This file listens for instructions from the main thread, runs the calculation, and returns the result.

// worker.ts
import { parentPort } from 'worker_threads';

function calculateFibonacci(n: number): number {
  if (n <= 1) return n;
  return calculateFibonacci(n - 1) + calculateFibonacci(n - 2);
}

// Listen for messages from the main thread
parentPort?.on('message', (n: number) => {
  const result = calculateFibonacci(n);
  
  // Post the result back
  parentPort?.postMessage(result);
});

Step 2: Integrate the Worker in the Main Application

Now, rewrite the Express server to spawn the worker thread when the /heavy endpoint is hit:

// server.ts
import express from 'express';
import { Worker } from 'worker_threads';
import path from 'path';

const app = express();

app.get('/heavy', (req, res) => {
  // Create a new worker thread running worker.ts
  const workerPath = path.resolve(__dirname, './worker.js');
  const worker = new Worker(workerPath);

  // Send the input value to the worker
  worker.postMessage(45);

  // Listen for the result from the worker
  worker.on('message', (result) => {
    res.send({ result });
  });

  worker.on('error', (err) => {
    res.status(500).send({ error: err.message });
  });

  worker.on('exit', (code) => {
    if (code !== 0) {
      console.error(`Worker stopped with exit code ${code}`);
    }
  });
});

app.get('/health', (req, res) => {
  // This request remains instant, even during active heavy calculations
  res.send({ status: 'OK' });
});

app.listen(3000, () => console.log('Server running on port 3000'));

When a user calls /heavy, the main thread spawns a worker thread, sends it the calculation instruction, and immediately returns to the event loop. The main thread is free to process /health calls instantly while the worker computes in the background.

When to Use Worker Threads

Worker Threads are powerful, but they should only be used for CPU-intensive JavaScript operations:

  • Good Use Cases: Image processing, PDF rendering, file encryption/decryption, data analysis, compiling assets.
  • Bad Use Cases: Querying databases, reading network sockets, sending emails. These are I/O operations, which Node.js already handles asynchronously using native C++ threads. Spawning workers for I/O tasks adds unnecessary thread management overhead.

Conclusion

Node.js’s single-threaded nature is highly optimized for asynchronous I/O, but vulnerable to CPU-blocking calculations. By leveraging the worker_threads module, you can delegate compute-heavy algorithms (like cryptography or image rendering) to background thread contexts, keeping the main event loop responsive to incoming HTTP traffic.