Get E-Book
Worker Threads & Shared Memory

Creating, Terminating, and Supervising Worker Threads

Ishtmeet Singh @ishtms/June 11, 2026/24 min read
#nodejs#worker-threads#workers#supervision#resource-limits

Put this code in hello.cjs, then run node hello.cjs. The .cjs extension tells Node to run this file as CommonJS, even if your project uses "type": "module".

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

if (isMainThread) {
  const worker = new Worker(__filename, { workerData: 21 });
  worker.once('message', value => console.log(value));
  worker.once('error', err => console.error(err));
} else {
  parentPort.postMessage(workerData * 2);
}

You'll see 42 in your terminal. We supplied the data, i.e. 21, through the workerData option, and the worker receives and reads that value from the workerData import. Then parentPort.postMessage() sends the multiplication result back to the parent, where the 'message' listener prints it.

We wrote the entire code inside hello.cjs, but the parent and worker run different branches. When you launch the file, isMainThread is true, so the parent enters the if branch and creates the worker. We passed __filename to new Worker(), which tells Node to load this same file in the worker also. Inside that worker, isMainThread is false, so it runs the else branch instead.

After sending the answer, the worker has no more work to do and no message listener waiting for more input. Nothing keeps its event loop running, so it exits on its own. We don't need to call terminate() here.

Choosing The Worker File

You can put the worker code in a separate file also. In CommonJS, use new Worker(require.resolve('./compute-worker.cjs')). require.resolve() finds compute-worker.cjs relative to the file containing this call and gives Node its absolute path.

If you pass './compute-worker.cjs' directly, Node looks for it relative to process.cwd(), i.e. the program's current working directory. Launching the program from a different directory can then make Node look in the wrong place. Using require.resolve() avoids that dependency on where you launched the program.

In an ES module, use import.meta.url to find the worker relative to the current file -

js
// Parent-side fragment in an ES module.
import { Worker } from 'node:worker_threads';

const worker = new Worker(new URL('./compute-worker.cjs', import.meta.url));
worker.once('error', err => console.error(err));

Here, import.meta.url gives the current module's location. new URL() uses that location to find compute-worker.cjs beside it. We pass the resulting URL to new Worker().

The parent uses ESM, but our worker file ends in .cjs, so the worker runs as CommonJS. That's allowed. Node uses the worker file's own extension and package configuration to choose its module format.

There are two other ways to supply worker code. You can pass a data: URL, which contains the code inside the URL itself. Node loads it through the ESM loader according to its MIME type. Or you can pass a string containing JavaScript and set eval: true; Node then runs that string as the worker's code. For a worker you'll keep editing, a separate file is easier to follow, and error stack traces can point to that file.

Notice the 'error' listener immediately after new Worker(). Creating the Worker object doesn't mean the worker has finished loading its code. The constructor checks its arguments and starts the native thread, then returns while that thread continues starting up.

An invalid constructor option can throw immediately in the parent. A syntax error in the worker file arrives later through the 'error' event. So a try/catch around new Worker() handles errors from that call, while the listener handles errors reported by the worker afterward. You need to account for both.

The Worker Gets A Copy Of Your Input

Let's pass an object instead of the number 21. In this example, we create the worker with config.limit set to 100_000, then change the parent's value to 200_000.

Save it as startup-data.cjs and run node startup-data.cjs -

js
// Save as startup-data.cjs and run with node startup-data.cjs.
const { Worker, isMainThread, parentPort, workerData } = require('node:worker_threads');

if (isMainThread) {
  const config = { limit: 100_000 };
  const worker = new Worker(__filename, { workerData: config });
  worker.once('error', err => console.error(err));
  worker.once('message', console.log);
  config.limit = 200_000;
} else {
  parentPort.postMessage(workerData);
}

You'll see { limit: 100000 }. Node copied config when we passed it to new Worker(). The assignment to 200_000 changes the parent's object only, so the worker still reads 100_000 from its own copy.

Even if the parent makes that assignment before the worker reaches its postMessage() call, the result stays the same. The copy was already made during construction. The worker isn't reading the parent's object directly.

The worker can change its own copy too, but those changes won't update the parent. If you want to change a running worker's settings, send a message with the new values and have the worker handle that message.

Functions can't be copied into workerData; including one makes construction throw. Put the function in the worker file, or import it there, and send the data it needs. Shared buffers are a different case because both threads can access their shared memory. We'll cover those in subchapter 5. For an ordinary object like config, each thread has a separate copy.

Giving Several Workers The Same Settings

Suppose several workers need the same service settings. We can store those settings in the parent with setEnvironmentData(). Workers created afterward get their own copies and read them through getEnvironmentData().

Save this as environment-data.cjs -

js
const {
  Worker, isMainThread, parentPort,
  setEnvironmentData, getEnvironmentData,
} = require('node:worker_threads');

if (isMainThread) {
  setEnvironmentData('service', { name: 'reports' });
  const worker = new Worker(__filename);
  worker.once('message', console.log);
  worker.once('error', console.error);
} else {
  parentPort.postMessage(getEnvironmentData('service'));
}

We stored { name: 'reports' } under the key 'service'. Inside the worker, getEnvironmentData('service') reads that value. The worker sends it to the parent, and the parent's listener prints { name: 'reports' }.

If we change the stored settings and then create another worker, the new worker gets the changed settings. The first worker keeps its earlier copy. Again, changing data in the parent doesn't update a worker that's already running.

Environment Variables

The data from setEnvironmentData() doesn't appear in process.env. These are separate features, despite the similar names.

To set the worker's environment variables, pass an env object to the Worker constructor. If you leave this option out, the worker gets a copy of the creating thread's environment variables. A later assignment in either thread changes that thread's copy only.

If a library inside the worker needs particular variables, include those keys in env. Copying all of process.env brings along every variable, including credentials and settings the worker may not need. On Windows, use consistent capitalization also. The worker's copied environment treats variable names as case-sensitive, even though the main thread handles them case-insensitively.

You can choose to share changes instead. Import SHARE_ENV from node:worker_threads and pass env: SHARE_ENV. Now an environment-variable assignment in the worker can change what the parent reads, and an assignment in the parent can change what the worker reads. For ordinary application settings, a small workerData object is easier to follow when you don't need these shared changes.

Arguments And Preloads

The argv option supplies arguments to your worker code. Node converts the values to strings and appends them to the worker's process.argv. The execArgv option supplies startup options to Node itself, such as a file to load before the worker's main file.

That file loaded in advance is called a preload. For example, --require ./preload.cjs tells Node to load preload.cjs before running the main file.

By default, the worker inherits the parent's execArgv, subject to restrictions on V8 flags and options that affect the whole process. So if the parent was started with that preload, the worker loads it too.

Now suppose preload.cjs creates a worker without checking isMainThread. The new worker loads the same preload and creates another worker. That one does the same thing, and worker creation keeps repeating. Put the thread check inside the preload, or supply an explicit execArgv list that leaves the preload out. A check in the main worker file would run too late, because the preload runs first.

ESM has another ordering detail. A static import loads and evaluates its dependency before the code in the importing module's body runs. If your code needs to read workerData to decide which module to load, read the value first and then use dynamic import() for that module. A static import would run before your code could make the choice.

Keeping The Worker Available For More Tasks

The worker in hello.cjs sent one answer and exited. To use a worker for several calculations, we need it to keep listening for input after each answer.

Save this as compute-worker.cjs. Here, parentPort.on('message', ...) registers a listener that runs each time the parent sends a message. It stays attached while the worker waits for the next task.

js
const { parentPort } = require('node:worker_threads');

function compute(limit) {
  if (!Number.isSafeInteger(limit) || limit < 0 || limit > 500_000_000) {
    throw new RangeError('limit must be an integer from 0 to 500000000');
  }
  let total = 0;
  for (let i = 0; i < limit; i++) total += i % 97;
  return total;
}

parentPort.on('message', msg => {
  if (msg?.type === 'shutdown') {
    parentPort.close();
    return;
  }
  if (msg?.type !== 'run') throw new Error('unknown worker command');

  try {
    const value = compute(msg.input);
    parentPort.postMessage({ type: 'result', id: msg.id, ok: true, value });
  } catch (err) {
    const error = err instanceof Error ? err.message : String(err);
    parentPort.postMessage({ type: 'result', id: msg.id, ok: false, error });
  }
});

parentPort.postMessage({ type: 'ready' });

The parent sends each task as a message with type: 'run', an input, and an id. The worker passes msg.input to compute(), then sends the calculated value back. It includes the same id so the parent knows which task produced that answer.

If the input is invalid, compute() throws an error. The catch reads the error message and sends it back with ok: false. The parent can then report that error to the caller. The worker itself stays alive and can accept another task.

When the parent sends type: 'shutdown', the worker calls parentPort.close(). This closes the message port, so it stops waiting for more commands. Our example has no other ongoing work, and the worker can exit. If you add a file or network client that needs closing, close that in the shutdown handler also.

Waiting Until The Worker Is Ready

The last line sends { type: 'ready' } to the parent. We send it after attaching the message listener, so the worker can already handle tasks by the time the parent receives it.

If the worker needs more setup, such as loading a parser, finish that setup before sending ready too. The parent can then wait for this message before assigning the first task.

Node also provides an 'online' event, but that only tells us the worker has started executing JavaScript. It doesn't tell us our parser has loaded or our other setup has finished. Node sends its internal online notification before evaluating the worker's main file. By the time the parent handles the event, the worker may have moved further along, since the two threads run independently. Our own ready message tells the parent exactly when our setup is done.

Here are the events the parent uses to watch the worker -

EventWhat it tells the parent
'online'The worker has started executing JavaScript
'message'A value arrived from the worker's parentPort.postMessage() call
'messageerror'Node couldn't recreate a received message as a JavaScript value
'error'An exception escaped the worker without being caught
'exit'The worker has stopped; this is its final lifecycle event

An uncaught exception stops the worker. The parent receives 'error', followed by 'exit'. Attach an error listener immediately after creating the worker. The Worker object follows EventEmitter's rules, so leaving 'error' unhandled can throw in the parent also.

A worker can exit without throwing an exception too. Node delivers the worker's posted messages before emitting 'exit', so a worker can send its result and then finish naturally. But if it exits without sending the result, the parent must reject the waiting task. An exit code of 0 still leaves the caller without an answer.

Tracking The Task In The Parent

Firing off messages is the easy part, we did that already. But now the parent has to do some actual bookkeeping - which task is pending, what answer came back, what died where. For that, let's build a small helper.

We'll put this helper in task-worker.cjs, beside compute-worker.cjs. createTaskWorker() hands you back a small object - ready to wait for the worker's startup, run() to send one task, and close() to stop the whole thing.

active is the variable holding whichever task we're waiting on right now. Each task is having an ID and a Promise for its answer. Promise.withResolvers() gives us that Promise together with its resolve and reject functions, so the message and error listeners can come and finish the job later.

This small helper takes one task at a time only, and uses the message format from compute-worker.cjs. It has no startup or task timeout, so a worker that stays alive without replying can leave the caller hanging there - just warning you upfront. And close() terminates the worker immediately, it doesn't bother sending the shutdown command.

js
const { Worker } = require('node:worker_threads');

function createTaskWorker(filename, workerOptions = {}) {
  const worker = new Worker(filename, workerOptions);
  const ready = Promise.withResolvers();
  let active = null, nextId = 1, stopped = false;
  function fail(err) {
    stopped = true;
    ready.reject(err);
    active?.reject(err);
  }
  ready.promise.catch(() => {});
  worker.on('error', fail);
  worker.on('messageerror', err => { fail(err); void worker.terminate(); });
  worker.once('exit', code => fail(new Error(`worker exited (${code})`)));
  worker.on('message', msg => {
    if (msg?.type === 'ready') return ready.resolve();
    if (msg?.type !== 'result' || msg.id !== active?.id) return;
    if (msg.ok) active.resolve(msg.value);
    else active.reject(new Error(msg.error));
  });
  return {
    ready: ready.promise,
    threadId: worker.threadId,
    async run(input) {
      await ready.promise;
      if (stopped || active) throw new Error('worker unavailable');
      const task = { ...Promise.withResolvers(), id: nextId++ };
      active = task;
      try {
        worker.postMessage({ type: 'run', id: task.id, input });
        return await task.promise;
      } finally {
        active = null;
      }
    },
    close() { stopped = true; return worker.terminate(); },
  };
}
module.exports = { createTaskWorker };

Let's use the helper first, error handling we'll go through after. Put this in main.cjs beside the other files and run node main.cjs -

js
const { createTaskWorker } = require('./task-worker.cjs');

async function main() {
  const worker = createTaskWorker(require.resolve('./compute-worker.cjs'));
  try {
    await worker.ready;
    console.log(await worker.run(100));
    console.log(await worker.run(200));
  } finally {
    await worker.close();
  }
}

main().catch(err => {
  console.error(err);
  process.exitCode = 1;
});
text
$ node main.cjs
4659
9327

Out comes 4659 first, then 9327. await worker.ready waits until the worker's ready message comes. Then worker.run(100) sends 100 as the first input and waits there for the answer, which console.log() prints. Next line is same thing with 200.

Both calls went to the same worker only. The finally block inside run() clears active after every task, that means the next call is free to send another input. But if you call run() while some task is still pending, it straight up rejects with worker unavailable. This helper doesn't queue up waiting tasks - we'll add that in the pool in subchapter 6.

The finally in main() calls close() whether the calculations succeed or throw, worker is getting closed either way. In this example, both calculations are awaited before we close the worker. Awaiting close() also waits for the termination to fully finish.

When Sending The Input Fails

See, run() saves the task in active before calling postMessage(). Sending can fail immediately also - for example, an input having a function inside can't be cloned into a value the worker can receive.

And because run() is async, that thrown error simply rejects the caller's Promise. The finally block still clears active, so one failed send doesn't make the worker look busy. You can send a valid input to the same worker after that, no problem.

When The Answer Arrives

The message listener checks the message type and the task ID. A matching result resolves the task with msg.value, or rejects it with the worker's error text. Anything unrelated gets ignored. Mind you, all this assumes the worker follows our protocol properly - a malformed or missing reply can leave the task waiting forever.

That return await task.promise is deliberate, it keeps execution inside the try until the task settles. Only after that does finally clear active and the caller continues. If you returned the Promise without the await, active would get cleared immediately and one more task could start too early. Small keyword, big difference.

When The Worker Fails

fail() is our one-stop sadness handler - it stops the helper from taking any new work and rejects both startup and any pending task. The worker's error event calls it whenever some exception escapes. The exit listener calls it also, because even a clean exit leaves a pending task with no answer coming. And rejecting an already-settled Promise has no effect, so calling it twice is harmless.

A messageerror also calls fail() and terminates the worker - if we couldn't even read the reply, no point keeping that worker alive. That little ready.promise.catch(() => {}) prevents an unhandled rejection when the caller closes the worker without awaiting readiness. Callers still get the original Promise, so awaiting it still reports startup failures properly.

Letting The Current Task Finish

Rule of thumb with this helper - await run() first, then call close(), if you want the current calculation to finish nicely. Calling close() in the middle of a calculation terminates the worker then and there, and the exit listener rejects the waiting task.

For a worker having resources to clean up, extend the helper to send the shutdown message after the active task finishes. Our compute-worker.cjs already handles that command by closing its message port. And if your worker is holding a file handle or network client, close those in the shutdown handler also.

One more thing - a proper supervisor needs deadlines for everything, i.e startup, tasks, and shutdown. If a task is taking too long, just rejecting its Promise won't interrupt the calculation running in the other thread, that one keeps burning CPU happily. Correct move is to stop assigning work to that worker and terminate it. During graceful shutdown, give some time for cleanup, then terminate if the worker still hasn't exited. We'll build more supervision around tasks in the worker-pool subchapter only - that's where all this comes together.

Stopping A Worker Before It Finishes

worker.terminate() tells Node, stop this worker's JavaScript as soon as you can, i.e right-now-ish. It gives back a Promise which resolves once termination has properly finished. But careful - whatever code was pending in the worker may simply never run. Cleanup code also, finally blocks also, all gone.

If your worker is holding clients or has other cleanup to do, send it the shutdown message first, then give it a moment to actually do that cleanup. Keep a timer on it also, so you're not waiting forever. Because terminate() gives no guarantee that a pending write or cleanup operation will finish, ok?

One more funny thing - if the worker already exited, calling terminate() after that can resolve with undefined only. So when the 'exit' event comes, save that exit information somewhere. Read it afterwards from there.

And save the reason you stopped the worker also. Node documents exit code 1 for termination, but if you stop a worker before it even reached 'online', you can get 0 on Node 24. So in a fuller supervisor, note down the error or the timeout at the time you ask for termination. That way you know why you stopped the worker, instead of doing guesswork from the exit number.

Inside a worker, process.exit() ends that one worker thread and cuts off its pending work. If cleanup needs to happen, close the resources properly and let the worker exit on its own instead. Much more civilized.

Some process operations simply aren't there in workers. A worker can't change the process's working directory, can't change user/group identity through the process mutation APIs, and process.abort() is not available at all. Process signals also get handled outside worker threads. The natural setup is, parent handles the shutdown signal and sends shutdown messages to its workers.

Keeping The Process Alive Until The Result Arrives

By default, an active worker keeps the whole process alive. This is called being referenced. Call worker.unref() and that requirement goes away - if nothing else is keeping the process alive, Node can exit then and there, even while the worker is still in the middle of calculating. Brutal, but that's the deal.

And here's the trap - awaiting a Promise for the result does not keep the process alive by itself. No. So when you actually need the worker's answer, leave it referenced only. unref() is for work you're prepared to lose when the rest of the process finishes. ref() brings back the keep-alive behavior, in case you changed your mind.

The raw Worker has a Symbol.asyncDispose hook which calls terminate() when its scope gets disposed. So the await using syntax can use that hook and stop the worker automatically on leaving the scope. Just know this - it's still plain termination, it doesn't send our helper's shutdown message first. And use an up-to-date Node 24 release for async disposal and the inspection APIs coming below, since some of those got added during the Node 24 series itself.

Limiting Worker Memory

Memory settings go in through the Worker's resourceLimits option. With our helper, pass { resourceLimits: { maxOldGenerationSizeMb: 128, stackSizeMb: 4 } } as the second argument to createTaskWorker(). Node applies those settings when it starts the worker, not after.

The raw Worker lets the parent read the applied settings through worker.resourceLimits. And code inside the worker can read the same thing by importing resourceLimits from node:worker_threads also.

Now, what does that 128 actually limit? Only the old-generation part of V8's heap, i.e the place where V8 keeps objects that survived long enough to get moved there. It does not cap all the memory the worker uses. ArrayBuffer backing memory (meaning the memory holding the buffer's actual bytes) and native allocations can grow outside that heap allowance. And the process needs memory for the parent itself, plus stacks for the workers. It adds up fast.

So adding up all the worker heap limits will not tell you the maximum memory the process can use. All those allocations belong to one process only, and one process-wide out-of-memory failure can still take down the whole thing. Everybody goes down together.

If some setting is not taking effect, first check the flags you used to launch Node. --max-old-space-size overrides maxOldGenerationSizeMb, and --max-semi-space-size overrides maxYoungGenerationSizeMb. The constructor option rules describe these overrides in detail.

Running out of allowed heap can terminate the worker straight away. Stack space running out behaves differently. Like, too many recursive calls may throw a RangeError which worker code can actually catch. But a very small stack can stop the worker from starting properly at all. So give the code enough stack to run in, and test any change using the work the worker will really be doing, not some toy example.

Reading Output From The Worker

A console.log() inside the worker normally shows up in the same terminal as the parent's output. Node does this by piping the worker's stdout to the parent's stdout. Stderr also, same treatment.

Pass stdout: true and Node stops piping that output automatically. Then the parent reads it through worker.stdout itself. stderr: true works the same way for worker.stderr. Both readable streams are there either way - these options only turn off the automatic piping, so you can handle the output yourself.

With stdin: true, the parent gets a writable worker.stdin. Whatever the parent writes there becomes available for the worker to read through its process.stdin. Full setup, like a mini shell.

Fun detail - worker stdio uses message passing internally. Which means the parent's event loop still needs to run for the output to get processed. So if the parent is blocked in some long synchronous function, the worker may have already called console.log(), but the text hasn't reached the terminal yet. Check the parent also before assuming the worker never reached that line. It probably did reach, the message is just stuck in traffic.

Whatever streams you choose to capture, read them. And keep calculation results in the task messages themselves, where the parent can match each answer to its task ID. Otherwise you'll be sitting there grepping log text to find the result. Nobody wants that.

Closing File Descriptors

trackUnmanagedFds defaults to true. With this option on, Node tracks the raw file descriptors managed through its fs.open() and fs.close() APIs, and closes the tracked descriptors when the worker exits - even if the worker got terminated abruptly.

FileHandle objects have their own cleanup arrangement. But descriptors opened by native code outside those Node APIs don't get added to this tracking automatically, so the option doesn't cover every file some dependency might open. Fair warning.

Finding Which Worker Failed

Give the worker a name in its constructor options, like reports, so you can tell what kind of work it does. Save its threadId at creation time also. Our helper does this immediately, because on Node 24 the raw Worker's ID becomes -1 after exit. If we waited until exit to read it, the original ID would be gone only.

And log the current task ID next to the thread ID. One worker runs several calculations no? So knowing which thread failed doesn't tell you which calculation was running. Keeping both IDs lets you connect the failure to the right task.

The raw Worker also provides ways to inspect the thread while it's running -

APIWhat it gives you
threadNameThe worker's thread name
cpuUsage()CPU time used by the worker thread
getHeapStatistics()Statistics about the worker's V8 heap
startCpuProfile()A recording of where JavaScript execution spends time
startHeapProfile()A profile of memory allocations
getHeapSnapshot()A snapshot of the objects in the worker's heap

These APIs live on the raw Worker held inside createTaskWorker(), so callers can't reach them directly through the returned object. Want to expose one? Add a method on that object which calls the matching method on the raw Worker. Simple pass-through.

One catch - an inspection call can fail if the worker exits before the call completes. Catch that error separately from the calculation's errors, otherwise one failed profiling attempt will replace the task's actual result. And that would be very confusing later.

Checking That Failures Reach The Caller

Start by sending an input that contains a function, then send a valid input to the same worker. First send should reject, because Node can't copy functions. Second one should calculate normally. If it says worker unavailable instead, check whether the failed send left some task sitting in active.

Next, make a worker exit without returning its answer. The waiting task should reject, even if the exit code is 0. Also try a worker that throws during startup - awaiting ready should surface that error.

Finally, start a long calculation and call close() before it finishes. The task should reject when the worker exits, and close() should finish once termination completes. Compare that with the other order, i.e await the calculation first and then close - that path should return the result normally.

One thing the helper doesn't have is deadlines. So it won't notice a worker that stays alive but never sends ready or a result - zombie type situation. If you add timeouts, test both cases, and make sure timed-out workers are properly stopped before you go assigning them more work. Otherwise the zombie comes back with the old task's answer and confuses everybody.