← Back to Blog
Concurrency

Worker Threads: run CPU-intensive tasks without blocking the Event Loop

How to offload CPU-intensive tasks with Node.js Worker Threads without degrading the Event Loop, and manage thread pooling with Piscina.js.

📅 ✍️ Antoine Coulon
nodejsworker-threadsevent-loopconcurrencypiscina

Running CPU-intensive tasks without degrading the Event Loop’s performance is possible, and Worker Threads are the tool made for exactly that. After distinguishing blocking operations from non-blocking ones, it’s time to tackle a concrete case: what do you do when a compute-heavy workload shows up in a Node.js application built around an Event Loop?

The problem: a single thread for everything

By default, Node.js code runs on a single thread, the main thread. This is the heart of the Event Loop-based concurrency model: a single execution queue processes events one after another, relying on non-blocking I/O to stay responsive. As long as the work consists of waiting (a network request, a disk read, a database response), this model is devastatingly effective.

The problem arises the moment a CPU-intensive task enters the scene. Since it runs on that same single thread, it monopolizes it: for the entire duration of its computation, the Event Loop can no longer process anything else.

Picture a web server where 99% of the endpoints perform non-blocking, fast, efficient processing that responds in under 200 ms. It takes just one endpoint handling a heavy workload (image resizing, video transcoding, compression, cryptographic computation) to slow down the entire application. For as long as that processing runs, all other requests wait in line behind it, including the ones that should have responded instantly.

This is both the beauty and the weakness of the Event Loop: an elegant, high-performance concurrency model whose efficiency can be undermined by a single misplaced operation.

The solution: offload with Worker Threads

Before acting, you first have to diagnose. An endpoint that degrades the whole system can be spotted with Event Loop observation tools like Clinic.js or the native perf_hooks module, which lets you measure, among other things, the Event Loop delay. Once the culprit is identified, it’s time to act.

Introduced as stable in Node.js 12, Worker Threads (node:worker_threads) offer a form of multithreading. A Worker Thread is a full-fledged OS thread: it has its own execution context and its own Event Loop, which lets it carry out CPU-bound operations without ever blocking the main thread. Multiple threads can be launched in parallel, communicate with each other via message passing, and share memory when it makes sense.

The idea, then, is to take the heavy computation off the main thread and delegate it to a worker. Here’s a minimal setup: the main thread stays free to handle other requests while the worker performs the computation.

// main.ts: stays on the main thread, the Event Loop is never blocked
import { Worker } from "node:worker_threads";

function runHeavyTask(input: number): Promise<number> {
  return new Promise((resolve, reject) => {
    const worker = new Worker(new URL("./worker.ts", import.meta.url), {
      workerData: input,
    });

    worker.once("message", resolve);
    worker.once("error", reject);
    worker.once("exit", (code) => {
      if (code !== 0) {
        reject(new Error(`Worker stopped with code ${code}`));
      }
    });
  });
}

// The call doesn't block: the computation goes off into a dedicated thread.
const result = await runHeavyTask(42);
// worker.ts: runs on a separate OS thread
import { parentPort, workerData } from "node:worker_threads";

function cpuIntensiveComputation(n: number): number {
  // Heavy processing: compression, hashing, image resizing…
  let acc = 0;
  for (let i = 0; i < n * 1_000_000; i++) {
    acc += Math.sqrt(i);
  }
  return acc;
}

const output = cpuIntensiveComputation(workerData);
parentPort?.postMessage(output);

This mechanism is already at the heart of many libraries in the ecosystem. Test runners like Vitest, Jest, or Playwright rely on Worker Threads to parallelize test execution, with a drastic impact on performance. If you run your test suites in parallel today, you’re already benefiting from Worker Threads without even knowing it.

A Worker Thread, a resource to manage like any other

The ease with which you instantiate a new Worker() can make it tempting to spin them up on the fly, one per task. That’s precisely the trap to avoid. Threads are expensive resources: their creation and destruction carry a non-negligible cost, both in time and in memory. Multiplying ephemeral threads means spending your time paying that setup cost rather than computing.

This is exactly the reasoning behind the Bulkhead pattern: a limited, expensive resource must be bounded and reused, never wasted. Concretely, you need to cap the number of threads created and maximize their reuse with a thread pool. You instantiate a fixed number of workers once and for all, typically pegged to the number of available cores, then hand out tasks to them as they come in.

Fortunately, there’s no need to write this pooling logic yourself. The ecosystem offers battle-tested solutions like Piscina.js, which manages a pool of Worker Threads, or even processes (node:child_process), for you, with task queuing, dynamic sizing, and thread reuse.

// pool.ts: a thread pool created only once
import Piscina from "piscina";
import { availableParallelism } from "node:os";

const pool = new Piscina({
  filename: new URL("./worker.ts", import.meta.url).href,
  // We bound the pool to the number of available cores
  maxThreads: availableParallelism(),
});

// Each call reuses a thread from the pool rather than creating a new one.
// Excess tasks are queued automatically.
async function handleRequest(payload: number): Promise<number> {
  return pool.run(payload);
}

// A thousand tasks, but never more than `maxThreads` live threads.
const results = await Promise.all(
  Array.from({ length: 1000 }, (_, i) => handleRequest(i))
);
// worker.ts: Piscina injects the payload directly as an argument
export default function cpuIntensiveComputation(n: number): number {
  let acc = 0;
  for (let i = 0; i < n * 1_000_000; i++) {
    acc += Math.sqrt(i);
  }
  return acc;
}

With this approach, you keep all the benefits of Worker Threads, a main Event Loop that stays responsive, without paying the hidden cost. The pool absorbs load spikes by queuing tasks, and the number of live threads stays under control no matter the incoming pressure.

▶ The video demonstration is available on the original LinkedIn post.

Conclusion

Node.js’s Event Loop is an excellent concurrency model for I/O-oriented workloads, but it remains vulnerable to a single CPU-intensive task that monopolizes the main thread. Worker Threads offer the countermeasure: move that computation off the critical path and hand it to a dedicated thread, letting the Event Loop keep serving other requests.

With one condition, however: treating these threads as the expensive resource they are. Rather than creating them on the fly, you bound them and reuse them through a thread pool, manually or by relying on a proven solution like Piscina.js. That’s the price of turning multithreading into a genuine performance lever, rather than a new source of waste.