DEV Community

Cover image for Stop Blocking the Event Loop: A Deep Dive into Node.js Worker Threads and Multithreading
Naveen Bukka
Naveen Bukka

Posted on

Stop Blocking the Event Loop: A Deep Dive into Node.js Worker Threads and Multithreading

Is Node.js Actually Single-Threaded?

The short answer: Yes and No. While the code you write is executed on a single thread, the environment it runs in is a multi-threaded powerhouse. To understand why, you have to look at the two halves of Node.js

  • The Single-Threaded Part (V8 Engine): This is where your JavaScript lives. Google’s V8 engine handles the call stack, memory heap, and execution on one Main Thread. This is why you don’t have to deal with complex “deadlocks” in your JS code.

  • The Multi-Threaded Part (Libuv): This C++ library is the secret weapon behind Node.js. It handles heavy operations like File System I/O, DNS lookups, and cryptography. When you call functions like fs.readFile() or fs.writeFile(), Node.js delegates the work to libuv, which schedules the task on its background thread pool (4 threads by default). Once the operation finishes, libuv sends the result back to the Event Loop so the callback can be executed.

  • The Hidden Thread Pool: By default, libuv maintains a thread pool of 4 worker threads to handle blocking tasks. Along with the main event loop thread, this means Node.js may utilize at least 5 threads internally.

The “single-threaded” myth around Node.js comes from two reasons. Node.js was marketed as an alternative to the traditional thread-per-connection model used by servers like Apache, highlighting its single JavaScript execution thread. Also, early versions of Node.js did not allow developers to create threads directly, reinforcing the belief that Node.js itself was single-threaded. In reality, while JavaScript runs on a single main thread, the runtime internally uses multiple threads through libuv and asynchronous I/O.

Threads vs. Cores: The OS Perspective

To understand why we need Worker Threads in Node.js, we first need to understand how the Operating System (OS) actually manages tasks on your hardware.

What is a Thread?

A thread is the smallest unit of execution that the OS can schedule. Think of a Core as a “Worker” and a Thread as the “Task List” that the worker is currently following. A single process (like your Node.js app) can have multiple threads running simultaneously.

The Life Cycle: Thread States

Threads aren’t always “working.” The OS Scheduler moves them through different states to maximize efficiency:

  • Ready: The thread is prepared to run and is just waiting for an available CPU core.
  • Running (In-Process): The thread is currently being executed by a core.
  • Waiting (Sleep): The thread is paused (e.g., waiting for a file to read or a timer to finish). It doesn’t use CPU power while sleeping.
  • Context Switching: This is the magic trick. The OS swaps threads in and out of the “Running” state so fast (milliseconds) that it looks like they are all running at once.

Concurrency vs. Parallelism

  • Concurrency (The Illusion): Two or more tasks making progress at the same time through context switching. (e.g., one core switching between two threads). It’s like a juggler with two balls but only one hand.
  • Parallelism (The Reality): Two or more tasks literally running at the exact same moment on different physical cores. (e.g., Core 1 runs Thread A while Core 2 runs Thread B).

If you spawn 10 threads on a CPU with 2 cores, the system can handle 10 tasks concurrently through context switching, but only 2 tasks can run in parallel at the same time, since parallel execution is limited by the number of CPU cores.

Spawning Threads: The worker_threads Module

When the Libuv thread pool isn’t enough — specifically when you have heavy JavaScript logic that would freeze the Event Loop — Node.js provides the worker_threads module. This allows you to create new threads that run in parallel with the main thread.

What is a Worker Thread in Nodejs

A Worker Thread is an isolated environment that runs its own V8 instance and its own Event Loop. Unlike the child_process module (which spawns an entirely new OS process), Worker Threads share the same memory space as the main thread, making them much more "lightweight" to spawn

To spawn a thread, you need two files: the Main Thread file and the Worker file.

// main.js

const { Worker } = require('worker_threads');

// 1. Create a new worker and point it to the worker file
// 2. Pass initial data via workerData
const worker = new Worker('./worker.js', {
  workerData: { num: 42, message: 'Start calculating' }
});

worker.on('message', (result) => {
  console.log(`Main thread received: ${result}`);
});

worker.on('error', (err) => console.error(err));
Enter fullscreen mode Exit fullscreen mode
// worker.js

const { workerData, parentPort } = require('worker_threads');

// 3. Access data passed from the main thread
console.log(`Worker received: ${workerData.message} for ${workerData.num}`);

// Perform a task 
const result = workerData.num * 2;

// 4. Send the result back to the main thread
parentPort.postMessage(result);
Enter fullscreen mode Exit fullscreen mode

code explanation

new Worker(path): This line spins up a new thread and begins executing the code in the file specified.

  • workerData: This is used for "initialization" data. It uses the Structured Clone Algorithm, meaning it creates a copy of the object and passes it to the worker_thread so the worker doesn't accidentally mutate the main thread's data.
  • parentPort.postMessage(): This is the communication bridge. It allows the worker to send messages back using same structured clone to the main thread's on('message') listener.
  • Lifecycle: Once the code in worker.js finishes executing and there are no active timers or listeners, the thread automatically shuts down, freeing up system resources.

Main Thread vs. Worker Threads: A Real-World Performance Comparison

complete code: https://github.com/naveen62/Node.js-Multithreading

function runSlow(allPasswords) {
  const start = performance.now();
  for (const password of allPasswords) {
    hashSync(password, ROUNDS);
  }
  const end = performance.now();
  return end - start;
}

function runFast(threadsCount, allPasswords) {
  const chunkSize = Math.ceil(allPasswords.length / threadsCount);
  const start = performance.now();

  const workerPromises = Array.from({ length: threadsCount }).map((_, i) => {
    const passwords = allPasswords.slice(i * chunkSize, (i + 1) * chunkSize);

    return new Promise((resolve, reject) => {
      // Create a new worker for each chunk. 
      // Note: In production, consider a Worker Pool to avoid thread creation overhead.
      const worker = new Worker(path.join(__dirname, 'hashWorker.js'), {
        workerData: { passwords, rounds: ROUNDS },
      });

      worker.on('message', resolve);
      worker.on('error', reject);
    });
  });

  return Promise.all(workerPromises).then(() => {
    const end = performance.now();
    return end - start;
  });
}

// worker.js
const { parentPort, workerData } = require('node:worker_threads');
const { hashSync } = require('bcryptjs');
const { passwords, rounds } = workerData;

for (const password of passwords) {
  hashSync(password, rounds);
}
parentPort.postMessage('done');
Enter fullscreen mode Exit fullscreen mode

runSlow

  • Sequential Execution: This function iterates through the entire array of passwords one at a time.
  • Linear Processing: It completes the hashing for one password before moving to the next, returning the total time taken once the entire list is processed.

runFast

  • Data Partitioning: It divides the password array into smaller “chunks” based on the number of threads requested.
  • Parallel Spawning: For each chunk, it creates a new Worker instance and passes the specific subset of data via workerData.
  • Promise Management: It wraps each worker in a Promise. Promise.all is used to track all background tasks and only returns the final timing once every thread has finished its work.

worker.js

  • Isolated Task: This script runs in a separate thread. It extracts the assigned passwords and the hashing rounds from workerData.
  • Communication: Once it finishes hashing its specific chunk, it uses parentPort.postMessage to notify the main thread that its task is complete.
// Routes
app.get('/slow', async (_req, res) => {
  try {
    const data = JSON.parse(await fs.readFile(PASSWORDS_FILE, 'utf8'));
    const allPasswords = data.passwords;

    const timeMs = runSlow(allPasswords);
    res.json({ 
      strategy: 'Sync (Main Thread)',
      time: `${(timeMs / 1000).toFixed(2)}s`, 
      count: allPasswords.length 
    });
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

app.get('/immediate', (req, res) => {
  res.json({ msg: 'This response is fast because the Event Loop is free!' });
});
Enter fullscreen mode Exit fullscreen mode

Route: /slow
This route demonstrates the critical “blocking” behavior in Node.js:

  • File Reading: It uses fs.readFile to asynchronously load the password list. While the I/O is non-blocking, the subsequent processing is not.
  • CPU Bottleneck: It calls runSlow, executing hashSync on the Main Thread. On a MacBook Air M4, this task took 15 seconds to complete. Because hashing is computationally expensive, this loop monopolizes the CPU.
  • Event Loop Blockage: During these 15 seconds, any request made to /immediate will not receive a response because the main thread is busy performing the hashing operation and the Event Loop cannot process new requests.
  • Response: The server only becomes “free” to send the JSON response and resume other tasks after the entire 15-second hashing process finishes.
  • During these 15 seconds, any request made to /immediate will not receive a response because the main thread is blocked while performing the hashing operation.
app.get('/fast/:threads', async (req, res) => {
  const threadsCount = parseInt(req.params.threads);
  const availableCores = os.availableParallelism();

  if (isNaN(threadsCount) || threadsCount <= 0) {
    return res.status(400).json({ error: 'Invalid thread count' });
  }

  // creating more threads than cores can lead to context switching overhead
  if (threadsCount > availableCores) {
    return res.status(400).json({ error: `Max logical cores available: ${availableCores}` });
  }

  try {
    const data = JSON.parse(await fs.readFile(PASSWORDS_FILE, 'utf8'));
    const timeMs = await runFast(threadsCount, data.passwords);

    res.json({
      strategy: 'Worker Threads',
      time: `${(timeMs / 1000).toFixed(2)}s`,
      count: data.passwords.length,
      threads: threadsCount
    });
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});
Enter fullscreen mode Exit fullscreen mode

Route: /fast/:threads

This route demonstrates how to unblock the Event Loop using Worker Threads:

  • Dynamic Thread Allocation: It accepts a threads parameter to decide how many workers to spawn. Using 10 threads on a 10-core MacBook Air M4, the processing time dropped from 15 seconds to just ~2 seconds.
  • Hardware Optimization: It uses os.availableParallelism() to ensure you don't spawn more threads than your CPU has logical cores, avoiding the performance hit of context switching.
  • Offloading Work: By calling runFast, the CPU-intensive hashing is moved off the Main Thread and distributed across multiple background threads.
  • Non-Blocking Experience: Because the Main Thread is no longer doing the heavy lifting, the Event Loop remains free. Even while the M4 is crunching hashes for 2 seconds, the server can still respond to other requests (like /immediate) instantly.

complete code: here

Top comments (0)