When to Use Worker Threads

Worker threads take CPU-bound work off the event loop, and every message is copied, so the size of the data decides it.

Syntaxnew Worker(filename[, options]) // options: { workerData, eval, transferList, resourceLimits, env, argv }

The event loop is the loop inside a Node process that picks up the next ready callback and runs it. One callback runs at a time. A function that computes for 400 milliseconds without awaiting anything therefore delays every other request in that process by 400 milliseconds. That is CPU-bound work: limited by arithmetic, not by waiting.

node:worker_threads runs JavaScript on another thread — a second line of execution inside the same process. Each worker gets its own event loop and its own V8 heap, and shares nothing by default.

How it works

new Worker(filename[, options]) starts one. Startup data goes in options.workerData, and the worker reads it from the module's workerData export. Results go back with parentPort.postMessage(value) and arrive on the parent's 'message' event. worker.terminate() returns a Promise that resolves with the exit code.

Shorthand and longhand

A worker is normally its own file. Setting eval: true makes the first argument the source code itself. Both forms produce the same thread:

new Worker('./hash-worker.js', { workerData: pw });
new Worker(sourceString, { eval: true, workerData: pw });

The cost of moving data

Every message is copied using the structured clone algorithm. Copying 50 MB can cost more than the work saves. Two ways out: list an ArrayBuffer in transferList to hand it over without copying, or allocate a SharedArrayBuffer that both threads read directly.

Do not use workers for I/O. A file read or a database query already waits outside the event loop, so a worker adds a copy and buys nothing.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Hash passwords with scrypt without stalling every other request for the duration.
  • Resize or re-encode an uploaded image inside the API process instead of adding a separate service.
  • Parse or validate a 200 MB JSON export without the health check timing out.
  • Decide, by measuring the payload size, whether moving a job to a thread is actually cheaper than running it inline.

More examples

The same work in its own file

The file form of the same thing the runnable example does with eval, wrapped so callers just await a value. Worth doing when the worker is more than a few lines, because a real file can be linted, tested and required normally. Note the Promise wrapper: the Worker API is events, and almost every call site wants one value back.

Example · javascript
// hash-worker.js
const { parentPort, workerData } = require('node:worker_threads');
const { scryptSync } = require('node:crypto');

// scryptSync is deliberately slow: that is the point of it for passwords,
// and the reason it must not run on the thread serving requests.
const key = scryptSync(workerData.password, workerData.salt, 64);
parentPort.postMessage(key.toString('hex'));


// routes/register.js
const { Worker } = require('node:worker_threads');

function hashPassword(password, salt) {
  return new Promise((resolve, reject) => {
    const worker = new Worker('./hash-worker.js', {
      workerData: { password, salt },
    });
    worker.once('message', resolve);
    worker.once('error', reject);
  });
}

Handing over a buffer instead of copying it

Transfer is what makes workers worth it for large binary payloads: image bytes, uploaded files, decoded audio. Reach for it whenever the message is megabytes rather than kilobytes. The byteLength of 0 is the part that surprises people, and it is exactly why the transfer was cheap.

Example · javascript
const { Worker } = require('node:worker_threads');

const pixels = new Uint8Array(12_000_000);   // ~12 MB of image data
// ... fill pixels from an upload ...

const worker = new Worker('./resize-worker.js');

// Second argument is the transferList. The ArrayBuffer moves to the
// worker rather than being cloned, so nothing is duplicated.
worker.postMessage({ pixels, width: 2000 }, [pixels.buffer]);

console.log(pixels.byteLength);  // 0 - this thread no longer owns it

worker.once('message', (resized) => {
  // resized came back as its own buffer; this thread owns that one.
});

A pool, so you are not paying startup each time

Shows why the decision is about rate as well as duration. One 5-second export job can afford a fresh worker; a thousand 20-millisecond hashes cannot, because startup would dominate. A pool of availableParallelism() workers, each looping on parentPort messages, is the usual answer.

Example · javascript
const { Worker } = require('node:worker_threads');
const { availableParallelism } = require('node:os');

// Starting a worker costs a few tens of milliseconds: a new V8 heap
// and a new event loop. For frequent short jobs, keep them alive.
class Pool {
  constructor(file, size = availableParallelism()) {
    this.idle = Array.from({ length: size }, () => new Worker(file));
    this.queue = [];
  }

  run(job) {
    return new Promise((resolve) => {
      const start = (worker) => {
        worker.once('message', (result) => {
          const next = this.queue.shift();
          if (next) next(worker);          // reuse immediately
          else this.idle.push(worker);
          resolve(result);
        });
        worker.postMessage(job);
      };
      const free = this.idle.pop();
      if (free) start(free);
      else this.queue.push(start);         // all busy: wait for one
    });
  }
}

Discussion

  • Be the first to comment on this lesson.