DEV Community

Ansh Sheladiya
Ansh Sheladiya

Posted on

Node.js Worker Threads: Multithreading, Performance and CPU-Bound Tasks

Node.js is built around a single JavaScript thread, which makes asynchronous I/O highly efficient but does not make CPU-heavy work magically non-blocking. Tasks such as image processing, encryption, compression, data transformation, and complex calculations can still occupy the main event loop and delay every other request.

Worker Threads provide a practical way to execute JavaScript on separate threads while keeping the familiar Node.js programming model. Instead of moving CPU-intensive work into another process, you can create workers that share the same process and communicate with the main thread through messages.

In this guide, we will build a runnable Worker Threads example from scratch. We will see how worker files communicate with the main thread, how to pass data into workers, how workers return results, and how this approach can improve responsiveness for CPU-bound workloads.

Understanding Node.js Worker Threads

The worker_threads module allows Node.js applications to run JavaScript code in parallel threads. The main thread can create a Worker, send it data, and continue handling other asynchronous operations while the worker performs CPU-intensive calculations.

A worker has its own JavaScript execution context and event loop. Communication normally happens through workerData, postMessage(), and the message event, while parentPort provides the worker with a communication channel back to the thread that created it.

The most important use case is CPU-bound work rather than ordinary asynchronous I/O. Reading files, querying databases, or making HTTP requests usually does not require Worker Threads because Node.js already handles those operations efficiently through its asynchronous architecture.

In the example below, the main thread creates a worker and passes it a large number. The worker performs an intentionally expensive calculation, reports progress through messages, and finally sends the result back before exiting cleanly.

This pattern is useful because the main thread remains available while the calculation is running. In a production application, you would normally reuse workers through a worker pool rather than creating a brand-new worker for every request, because worker creation itself has overhead.

const { Worker, isMainThread, parentPort, workerData } = require("node:worker_threads");

// This example demonstrates CPU-bound work using Worker Threads.
// Run it with: node worker-threads-demo.js

if (isMainThread) {
  console.log("[Main] Application started.");
  console.log("[Main] Running on the main JavaScript thread.");

  // The input represents how many values the worker should process.
  const totalItems = 50000000;

  console.log(`[Main] Preparing worker for ${totalItems.toLocaleString()} items...`);

  // Create a Worker using the current file.
  // The workerData object is copied into the worker's execution context.
  const worker = new Worker(__filename, {
    workerData: {
      totalItems,
      multiplier: 2
    }
  });

  console.log("[Main] Worker created successfully.");
  console.log("[Main] The main thread can continue doing other work.");

  // Demonstrate that the main thread remains responsive.
  let heartbeat = 0;
  const heartbeatTimer = setInterval(() => {
    heartbeat += 1;
    console.log(`[Main] Heartbeat ${heartbeat}: event loop is still responsive.`);

    if (heartbeat >= 5) {
      clearInterval(heartbeatTimer);
    }
  }, 500);

  // Receive progress messages from the worker.
  worker.on("message", (message) => {
    if (message.type === "progress") {
      console.log(`[Main] Worker progress: ${message.percent}%`);
      return;
    }

    if (message.type === "result") {
      console.log("[Main] Worker completed the calculation.");
      console.log(`[Main] Processed items: ${message.processed.toLocaleString()}`);
      console.log(`[Main] Final result: ${message.result.toLocaleString()}`);
      console.log(`[Main] Worker duration: ${message.duration} ms`);
    }
  });

  // Worker-level errors should always be handled.
  worker.on("error", (error) => {
    console.error("[Main] Worker failed:", error);
  });

  // The exit event tells us that the worker thread has stopped.
  worker.on("exit", (code) => {
    if (code === 0) {
      console.log("[Main] Worker exited successfully.");
    } else {
      console.error(`[Main] Worker stopped with exit code ${code}.`);
    }
  });

  // You can also send additional messages to a running worker.
  // The worker in this example does not need additional commands,
  // but postMessage() is useful for interactive worker designs.
  console.log("[Main] Waiting for the worker result...");
} else {
  console.log("[Worker] Worker thread started.");
  console.log(`[Worker] Received ${workerData.totalItems.toLocaleString()} items.`);

  const { totalItems, multiplier } = workerData;
  const startTime = Date.now();
  let result = 0;

  // Calculate progress roughly every 10% of the workload.
  const progressInterval = Math.max(1, Math.floor(totalItems / 10));

  for (let index = 1; index <= totalItems; index += 1) {
    // Simulate CPU-heavy work by repeatedly calculating values.
    const value = (index % 1000) * multiplier;
    result += Math.sqrt(value * value + index);

    // Send progress updates without waiting for the main thread.
    if (index % progressInterval === 0) {
      const percent = Math.round((index / totalItems) * 100);

      parentPort.postMessage({
        type: "progress",
        percent
      });
    }
  }

  const duration = Date.now() - startTime;

  console.log("[Worker] CPU-intensive calculation completed.");

  // Send the final result back to the main thread.
  parentPort.postMessage({
    type: "result",
    processed: totalItems,
    result,
    duration
  });

  console.log("[Worker] Result sent to the main thread.");
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

Worker Threads are one of the most useful Node.js features when an application needs genuine parallel execution for CPU-bound JavaScript. They allow expensive calculations to run away from the main event loop, helping the application remain responsive while the work is being performed.

They should not be treated as a replacement for asynchronous programming or as a solution for every performance problem. For database queries, network requests, and filesystem operations, normal Node.js asynchronous APIs are usually the better choice.

For larger systems, consider building a worker pool so that workers can be reused instead of repeatedly created and destroyed. Combined with sensible workload sizing, error handling, monitoring, and backpressure, Worker Threads can become a powerful foundation for CPU-intensive Node.js services.

Top comments (0)