Get E-Book
Worker Threads & Shared Memory

Workers vs Processes

Ishtmeet Singh @ishtms/June 11, 2026/38 min read
#nodejs#worker-threads#child-processes#cpu-bound

One CPU-heavy function can fully freeze a server that is using async I/O everywhere else. Yes, fully freeze.

The event loop still has one JavaScript stack only, on the main thread. If that stack is busy running some long calculation, then timers are waiting. Socket callbacks also waiting. Promise continuations also just sitting there. The OS can keep doing its network side, and libuv can keep watching the handles, no problem - but JavaScript in that Node environment will run again only after the current stack returns. Till then, nobody's JavaScript is moving.

Small demo -

js
function crunch(limit) {
  let total = 0;
  for (let i = 0; i < limit; i++) total += i % 97;
  return total;
}

That loop spends its whole time running JavaScript instructions. Meanwhile the socket path is idle, the file-descriptor path is idle, the database path also idle - everybody is waiting for this one loop to finish. And that is exactly what CPU-bound means i.e work where the time goes in CPU running, not in waiting for I/O.

Calling that function from a request handler keeps the main thread busy till crunch() returns, obviously. And no, you can't get out of this with scheduling tricks - Promise.resolve().then() only changes the order of things inside that same thread. setImmediate() queues a later callback on that same thread only. The built-in async file and crypto APIs may use libuv's thread pool, sure, but that pool runs Node's native work items - your JavaScript function stays on whichever JavaScript thread called it.

Worker threads exist for exactly this gap.

A worker thread is a JavaScript execution thread that Node manages, inside the same OS process. It runs JavaScript through V8, but on another OS thread. It has its own V8 isolate, its own Node environment, its own libuv event loop - full setup. And it talks to the thread that created it through message channels only, nothing shared directly.

Quick definitions, because these three get mixed up all the time. The main thread is the first JavaScript thread of the process - it starts the program, runs the entrypoint, does most of the server-side managing, and usually owns the listeners that accept traffic also. A worker thread is extra JavaScript running capacity inside that same process. And a child process is a whole other OS process with its own PID, address space, environment snapshot, stdio wiring, full process lifecycle.

So those are the three places where code can run -

text
main thread      same Node process, initial JS thread
worker thread    same Node process, another JS thread
child process    another Node or non-Node process

The choice starts with one question - how separate do the things actually need to be?

Use the main thread for normal async servers, routing, I/O callbacks, stream work, small synchronous work - all the regular stuff. Use a worker thread for CPU-bound JavaScript that can run in parallel with the main thread and send a result back through messages. Use a child process when you need a separate OS process i.e its own memory, its own environment, stdio pipes, outside executables, someone watching that process from outside, or just better crash safety - child dies, you keep running.

That idea is basically the whole chapter.

The main thread is the easiest place to run code, no doubt. A function call stays a function call. Object references stay in one heap. A request handler can read shared application state, call local helpers, update in-memory caches, return, done. The cost is scheduling only - one long synchronous function holds the stack, and the whole event loop waits behind it.

A worker thread adds one more place to run JavaScript. The price is that now another thread is in between and messages have to go across - the parent must send the input, the worker must return the output, and both sides must agree on what a task looks like, what an error looks like, and who owns any binary memory that crosses over. The gain - parallel JavaScript running inside the same process.

A child process adds a full separate OS process. The price is process startup, process memory, stdio or IPC wiring, and the whole operational lifecycle that comes with it. The gain is separate process state. A child can run a different executable altogether. A child can have its own cwd, env, uid, gid, stdio policy, memory profile. And a child can crash as a process while the parent is still alive to watch it happen and replace it. (Dark, I know. But very useful.)

The three also fail in their own ways. A main-thread CPU stall shows up as late timers, high event-loop delay, and callbacks that bunch up together after the calculation finally returns. A worker mistake usually shows up as messages arriving late, clone cost eating the whole task, or one unhandled worker 'error' event that nobody attached a handler to. (Attach the handler. Seriously.) A child-process mistake shows up as missing executable, blocked stdio pipe, bad PATH, failed IPC send, or a process exit that leaves some active task stuck forever.

So the first decision itself is local - keep the managing local, move the CPU-heavy JavaScript to a worker, move the work that needs its own process to a child process. That's it, simple as that.

A Worker Is Another Node Environment

node:worker_threads is the built-in module for making JavaScript worker threads in Node. The Worker class is the parent-side object which starts one worker and represents it. From JavaScript it looks tiny only -

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

const worker = new Worker(require.resolve('./hash-worker.js'));

Now the parent has a Worker object, and the worker thread itself is getting started by Node. One small thing here - require.resolve() gives the constructor an absolute path counted from this CommonJS file. If you pass some string like ./hash-worker.js directly, that gets resolved from process.cwd() instead, which means the path depends on how the application was launched. Who wants that, no? Use require.resolve() and stay happy.

The object in the parent is just your handle for controlling the thing. Underneath it, Node makes the worker's messaging channel, makes a native worker handle, and starts a new OS thread. Inside that thread, Node creates the runtime pieces needed for running JavaScript i.e a libuv loop, a V8 isolate, isolate data, a V8 context, and a Node environment. After that environment exists, Node loads the worker entrypoint and runs that worker's event loop.

The worker isolate is the V8 isolate made for that worker. It owns the worker's JavaScript heap and the V8 running state. Main thread has its own isolate. Another worker gets another isolate. Object identity belongs to one isolate only - a normal object made in the main thread heap lives in that heap, and a normal object made in the worker heap lives in the worker heap.

The libuv loop is also separate. Main thread has one loop, each worker has its own loop. Timers put inside the worker are timers on the worker's loop itself. Async filesystem callbacks started inside the worker come back to the worker only. A setImmediate() inside the worker queues work on that worker's check phase. And the main thread keeps running its own callback queues and its own loop on its side.

Node's own code is quite direct about all this. Worker startup makes a uv_loop_t for the worker thread, makes a new V8 isolate with that loop attached, creates isolate data, builds a context, creates a Node environment, creates the worker-side message port, loads the entrypoint, and then runs the event loop for that environment. The exact internal class names keep changing across Node releases, but the runtime side stays stable for Node v24 i.e one worker gets its own isolate and its own loop, inside the same process.

That "inside the same process" part, remember it well. A worker shares the process-level reality with the main thread. Process has one PID only. Resource usage rolls into one RSS number from the operating system. A native crash or process-wide fatal failure takes down the whole thing - main thread also, all workers also. Some per-thread state is copied or configured separately for the worker, but the process remains one process only.

Because of this same-process placement, a worker is a much lighter thing than a child process. A worker is just a thread, an isolate, an environment, a message port, and some internal records inside the parent process. No separate executable path. No separate PID which some outside supervisor can watch. Node exposes worker.stdout and worker.stderr as readable streams and by default pipes them to the parent's own matching streams. If you pass stdout: true or stderr: true, that automatic piping stops, which means the parent can read the stream directly. But note - these are worker API streams only, not separate process-level stdio descriptors. The parent sees the worker through the Worker object, that's it.

The environment inside the worker still feels like normal Node. It has process, timers, modules, async resources, access to the built-in APIs, everything. A worker can open files, start network clients, create timers, even create more workers (yes, workers making workers, it's allowed). But all those actions happen from the worker's Node environment, and the callbacks come back to the worker's event loop itself. A timeout made inside a worker fires inside that worker. A Promise continuation made inside a worker runs inside that worker. A module loaded inside a worker lives in that worker's module cache.

The module cache point is easy to miss, so let me say it loudly. Loading ./parser.js in the main thread and loading ./parser.js in a worker - both make state in different environments. Top-level module state inside the worker is worker-local only. If that module keeps some in-memory cache, the worker has its own copy of it. Four workers load it? Four worker-local copies exist. For isolated CPU work that's actually useful. But code which expected one process-wide JavaScript object is in for a surprise.

The worker also has a JavaScript-visible identity, have a look -

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

isMainThread is true in the initial thread and false in any user-made worker. threadId is the numeric worker-thread id which Node gives. Main thread reports 0, a worker reports some other value. And parentPort is the worker-side message port connected to the Worker object the parent created.

The usual one-file worker pattern uses the same file for parent code and worker code also -

js
if (isMainThread) {
  const worker = new Worker(__filename);
  worker.once('message', console.log);
} else {
  parentPort.postMessage({ threadId });
}

The guard is the whole control point here. Main branch makes the worker. Worker branch sends the result. But if you put the worker construction at top level outside an isMainThread branch, then each worker loads the same file and starts another worker during startup also, and that one starts another one, and... you can see where this is going. That recursion eats threads and memory until Node or the operating system stops it. Not the fun type of recursion.

The entrypoint rule is simple. new Worker(__filename) loads the current file again in the worker thread. Top-level code runs again. Top-level imports run again. Top-level side effects run again. The branch just makes one file run in two modes i.e parent mode and worker mode.

A helper function can be used by both branches, while the side effects stay behind the branch -

js
function runTask(input) {
  return crunch(input.limit);
}

The function itself is normal JavaScript, nothing special inside it. In the worker branch, runTask() takes data which came through parentPort. In the main branch, the parent makes a worker and listens for the result.

isInternalThread is one more, smaller identity flag -

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

console.log(isInternalThread);

In Node v24, that value is true inside Node-made internal workers like the loader thread, and false in normal application code and user-made workers. Most application worker code only reads it for checking things, that's all. For your daily branching and logging work, isMainThread and threadId are the ones doing the actual job.

Put worker identity in your logs, seriously. A CPU-heavy service with a pool of workers needs to know which worker did a task, which worker threw, which worker got replaced. threadId is enough for matching that in logs. But application task IDs still belong in the message protocol only, because one thread handles many tasks over its lifetime - thread ID and task ID are answering two different questions.

Parallel JavaScript Means Another Thread Runs JS

Parallel JavaScript running means two JavaScript stacks running at the same time on different OS threads. Main isolate can be managing the requests while the worker isolate runs some calculation. The OS scheduler decides where those threads actually run. On a machine with free CPU cores, both can move ahead together.

And this is not the same as Promise concurrency, please don't mix the two.

A Promise callback runs when the current stack clears and the runtime drains the relevant queue. It is still JavaScript on the same thread only. Ten Promise callbacks can stand for ten pending I/O operations, sure, but when their JavaScript continuations actually run, they run one at a time on that one thread. No parallelism there at all.

Workers add one more JavaScript thread inside the process. Main thread goes back to the event loop while the worker computes. Worker posts a message when it has some data to return.

What you see is delay. A synchronous CPU loop in the main thread delays unrelated callbacks also - your timers, your incoming request handlers, everything waits. Same loop inside a worker? It delays that worker's callbacks only. Main thread keeps moving ahead because its stack is clear. The worker may still eat one full CPU core, and enough workers can eat all the available cores, but at least that load has moved away from the thread running the server.

That load shift is useful when input and output are small and known. A worker can receive a JSON-serializable task description, read a file path, process a buffer, compute a result from a small payload, whatever. The result crosses back as data. A workload which needs live access to parent heap state that keeps changing is a bad fit, because that state is sitting in another isolate, sorry.

Put the earlier function into a file -

js
function crunch(limit) {
  let total = 0;
  for (let i = 0; i < limit; i++) total += i % 97;
  return total;
}

Now separate parent and worker behavior -

js
if (isMainThread) {
  const worker = new Worker(__filename);
  worker.once('message', value => console.log(value));
} else {
  parentPort.postMessage(crunch(500_000_000));
}

The calculation runs in the worker thread now. Main thread keeps accepting connections, handling timers, running other callbacks, while the worker owns the CPU-heavy loop. The result crosses back as one message.

That crossing is a big deal, because memory is separate at the JavaScript heap level. Parent receives data prepared for the parent isolate. Object identity from the worker heap stays in the worker heap only. That difference will affect every worker design you write after the first demo, believe me.

Workers fit CPU-bound JavaScript nicely. Parsing some big JavaScript bundle, rendering a heavy template, compressing data through a JS implementation, running a costly validation pass, calculating a large in-memory report - all these fit a worker when you can package the work into inputs and outputs.

Async I/O fits the existing Node model already. A database call, HTTP request, file read, DNS lookup, socket write - all of these already have a non-blocking path through the platform. Moving those waits into a worker usually just adds startup cost and message cost, because the main thread could have waited through the existing async APIs anyway.

The libuv thread pool is a separate thing, don't confuse it with workers. Node uses that pool for built-in native work - many filesystem operations, some crypto operations, some DNS paths. The pool is process-global and shared between the main event loop and every worker event loop, which means creating a Worker does not give that environment a private libuv pool. Pool items are native tasks scheduled by Node internals. A user-created Worker runs JavaScript source in a Node environment. Both use OS threads, yes, but who owns them and what job they do are totally different.

Knowing the difference saves you from one very common wrong fix. Suppose your server has slow requests because one handler awaits ten database calls. Adding a worker thread gives you another JavaScript thread, fine, but the database time is still remote I/O time - the worker is also just sitting and waiting now. What belongs there is a concurrency limit, connection-pool sizing, a query fix, or a request budget. But suppose instead the server is slow because one handler is sorting ten million objects synchronously. Now a worker thread can actually move that CPU time away from the main thread. Big difference, no?

Another wrong fix - the "async" wrapper around CPU work. Wrapping a synchronous loop in an async function changes only the return type, now it's a Promise. The loop still runs on the current JavaScript stack till it returns or hits a real suspension point. A worker actually changes the thread which runs the code. For CPU-bound code, that's the difference which counts.

Timers follow the same rule. Breaking CPU work into setImmediate() chunks can make the main thread feel less stuck, because each chunk gives the event loop a chance to run. But that still runs the CPU work on the main thread only - you are giving up speed and taking on complexity, just to get smaller blocked gaps. A worker keeps the work as CPU work and moves it to another JavaScript thread altogether.

Both techniques can be valid, honestly. Chunking is local and cheap when the task is small and divisible. A worker is better when the task is large, self-contained, and worth the message cost. Your call.

The Worker Memory Rule

The worker memory rule is simple - normal JavaScript objects belong to one worker isolate at a time. Data crosses through message APIs only. The crossing uses one of three categories -

text
copy      value is cloned for the receiving side
transfer  backing store moves to the receiving side
share     SharedArrayBuffer memory is visible to both sides

That much is enough for this subchapter. Later sections cover the actual details.

The physical layout looks like this -

text
main thread
  isolate A, heap A, libuv loop A
  Worker object -> message port

worker thread
  isolate B, heap B, libuv loop B
  parentPort -> message port

A plain object sent through postMessage() gets cloned into the receiver's heap using the structured clone path. Receiving side gets its own object graph. Sender keeps the original graph. Nobody is sharing anything here.

js
worker.postMessage({ limit: 500_000_000 });

The worker receives a payload with the same data structure. You mutate the received object? You are mutating the worker's copy only. The parent copy remains parent-side state, untouched.

ArrayBuffer can be copied or transferred. Copying gives both sides separate backing stores with the same bytes at send time. Transferring moves the backing store to the receiving side and detaches it from the sender, which means the sender can't touch it anymore after that. SharedArrayBuffer gives both sides access to the same backing memory. Chapter 2 already covered the binary-memory terms, so here the worker-specific point is placement only. Workers give those memory categories an actual thread crossing.

The no-sharing rule is also a design limit. Message payloads should be task data and result data, nothing fancy. Large object graphs pay clone cost. Large binary payloads need a proper copy, transfer, or share decision. Shared memory means both sides have to coordinate also, so later subchapters give it its own treatment with Atomics.

Task messages should be boring on purpose -

js
worker.postMessage({
  type: 'render',
  id: 'task-42',
  input: { template, data },
});

That payload has a task type, a task ID, and serializable input - nothing more. The worker can send a matching result -

js
parentPort.postMessage({
  type: 'done',
  id: 'task-42',
  value,
});

That format keeps the crossing explicit. The task ID connects a result to a parent-side Promise, callback, or request. The type field gives the worker a small protocol. Later pool code can reuse the same format across long-lived workers also.

Errors need the same treatment. An Error object can cross some clone paths with useful fields, sure, but production protocols usually send controlled error data only - name, message, code, and a task ID. The receiving side should treat that as plain data from another running place. If the application's API wants an actual error, reject a parent-side Promise with an error object made locally.

Shared memory changes the data path. With SharedArrayBuffer, both sides can see the same backing memory. That is the reason it belongs in this chapter. It also means things stay correct only when there is a protocol around reads, writes, and notification. Subchapter 5 covers that protocol. For now the safe model is simple - cloning gives independent values, transferring moves ownership, sharing keeps one backing store visible to both threads.

That memory rule explains why worker APIs feel different from calling a function. A function call can pass object references inside one isolate, easy. A worker message crosses into another isolate. The input must survive that crossing i.e you send stuff which can be cloned, transferred, or shared. The output has to cross back the same way.

It also explains why comparing with child processes is a different thing altogether. Ordinary child-process IPC serializes messages across a process channel. Node can pass supported handles also, like server and socket handles, through child-process IPC - but that handle path stays in process IPC only. OS-level shared memory exists outside ordinary Node child-process messaging, sure, but that's some other design with platform-specific setup and all. Worker threads give Node applications a built-in JavaScript-level shared-memory path through SharedArrayBuffer, no extra setup.

What Actually Happens in new Worker()

The parent call starts in JavaScript itself -

js
const worker = new Worker(__filename);

Construction checks the filename or code string, constructor options, environment options, resource limits, everything. The parent also creates the message channel connecting worker.postMessage() on the parent side to parentPort on the worker side. Parent gets a Worker object immediately, but the worker code still has lots of startup work ahead of it.

Node gives out a thread ID before the worker runs any user JavaScript. That ID shows up as worker.threadId in the parent and threadId inside the worker. It's Node's own worker-thread id, so treat it as a checking value for logs and routing inside worker APIs, nothing more.

The native startup path then creates an OS thread using libuv's thread wrapper. Inside that new thread, Node prepares the worker runtime. First it sets up a fresh uv_loop_t. That loop belongs to the worker environment only - timers, immediates, async handles, native completions created from worker code, all of them bind to that loop.

Next comes V8 state. Node creates a new V8 isolate for the worker and ties it to the worker loop and the Node platform. The isolate gets resource limits made from worker options and process defaults, its own array-buffer allocator state, its own isolate data also. This worker isolate is the thing in V8 which keeps ordinary object heaps separate.

After that, Node builds a V8 context - the global running context for the worker's JavaScript. Then Node creates a whole Node environment around that context. The environment joins V8, libuv, process state, module loading, timers, microtasks, native bindings, and the worker's message port, everything.

That environment is the Node unit which makes the worker feel normal. Timers attach to it. Native module bindings attach to it. Async hooks and diagnostics can see resources created inside it. The module loader resolves the worker entrypoint inside it also. And the worker's process object is a JavaScript view over process and environment state, with worker-specific restrictions wherever Node has to protect process-wide state.

The worker receives copied process-like state wherever Node makes copies. Environment variables default to a copy of the parent thread's process.env (the SHARE_ENV option changes that one specific rule). process.execArgv is inherited by default, with restrictions on options that apply to the whole process. So yes, the worker has process because it's still a Node environment, but several process-level behaviors are held back by the shared process. Signals, title changes, parent-process IPC access - all that belongs to process-level state only.

The message port gets installed before any user code runs. In the parent, worker.postMessage(value) sends through the parent-side port. In the worker, parentPort.on('message', handler) reads from the worker-side port. Same channel carries messages in both directions, through the Worker object's 'message' event and parentPort.postMessage().

The parent can attach listeners before the worker entrypoint finishes loading -

js
const worker = new Worker(__filename);

worker.once('online', () => console.log('online'));
worker.once('message', console.log);

'online' marks the point where the worker thread has started running JavaScript. It says the environment is running, that's all. It says almost nothing about application readiness. A worker which loads some big model, opens files, sets up a parser - it still needs an application-level ready message if the parent cares about warm state. Don't confuse the two.

Then Node loads the entrypoint. Filename worker? It loads the file through the CommonJS or ESM loader, following the same package and extension rules used everywhere else in Node. eval worker? It treats the first constructor argument as source text. data: URL? It uses the ESM loader rules for that URL. And now the worker is running user code in its own isolate. Finally!

After entrypoint loading, Node spins the worker event loop. The worker stays alive while referenced handles, active requests, message ports, timers, or other work keep its environment alive. When the worker finishes, or throws uncaught, or receives termination - Node tears down that environment, disposes the isolate, closes the worker loop, joins the thread, and emits the parent-side events.

Teardown is one more place where you see that the threads are separate. The worker's JavaScript can be fully finished, but parent-side cleanup still has to see the worker exit. The parent may have an active task waiting for a result. It may have a timeout handle running. It may have metrics tied to that worker ID. A clean worker design keeps one parent-side completion path for every task - result message, worker error, worker exit, timeout, or cancellation, whichever comes first.

The event sequence is important when code reports failure -

js
worker.once('error', err => console.error(err));
worker.once('exit', code => {
  if (code !== 0) console.error({ code });
});

An uncaught exception inside the worker emits 'error' on the parent-side Worker object, and then the worker exits. Please attach an 'error' listener, because the Worker object follows the normal EventEmitter error contract, which means an unhandled 'error' event will throw. With a listener in place, the parent can log the failure, reject the active task, and decide whether to make another worker or not.

And remember, all this flow is still inside one OS process. A JavaScript exception in a worker stays contained in that worker's lifecycle, that's the good news. A process-wide fatal error still has process-wide reach. A child process is the one which gets its own separate OS process, so a fatal failure in the child normally just exits that child and leaves the parent process sitting there watching the exit. Different tool, different failure reach.

Startup Cost Is Part of the API

new Worker() is not free, ok. It costs way more than queuing some callback. One new Worker() creates thread state, a V8 isolate, a Node environment, module-loader state, and a message channel also. And before the worker can even touch your actual work, the entrypoint may load modules, parse source, set up caches, make buffers, create timers - all this happens first, then your task.

So worker startup overhead is that fixed cost you pay before any useful task work starts. Keep that number in your head, it will keep coming back.

A one-off worker is fine when the task is big enough. A 700 ms CPU calculation can absorb few tens of milliseconds of startup, no problem. A 2 ms formatting task loses that trade badly - the worker would spend more time becoming ready and crossing messages than doing the actual useful operation. Wrong tool for that job.

Same cost shows up in memory also. Each worker has its own V8 heap and its own Node environment. On top of that - stack space, native structures, libuv loop state, and module cache entries for whatever modules that worker loads. So four workers loading the same big parser means four worker-side module instances and four sets of runtime state. The parser does not get shared just because you wanted it shared, no.

Serialization cost is separate also. Inputs and outputs crossing between parent and worker may be cloned, transferred, or shared. Cloning some big object graph can eat the whole task. Transferring a buffer avoids a copy, but ownership moves - after transfer, the sending side can't touch it anymore. Sharing memory avoids clone and transfer for the backing store, but now correctness is your headache, because managing that access is on you. Nothing free anywhere in this list, see.

The first design question is task size. Second one is task count. Simple as that.

For one rare, large CPU job, a one-off worker can be clean -

js
function runReport(reportId) {
  return new Promise((resolve, reject) => {
    const worker = new Worker(require.resolve('./report-worker.js'));
    let replied = false;

    worker.once('message', value => {
      replied = true;
      resolve(value);
    });
    worker.once('error', reject);
    worker.once('exit', code => {
      if (!replied) {
        reject(new Error(`Worker exited before replying (code ${code})`));
      }
    });

    worker.postMessage({ reportId });
  });
}

The worker starts, receives one report request, sends one result, and exits (or the parent calls terminate() on it). The parent-side Promise finishes with the result, or an uncaught worker error, or an exit that came before any reply. Production code can wrap its own timeout or cancellation policy around that Promise. And the code stays manageable because worker lifetime and task lifetime are almost the same thing here. One task, one worker, one goodbye.

Repeated tasks point toward reuse. A worker pool keeps N workers alive and dispatches many CPU-bound tasks through them. This chapter only names that direction - subchapter 6 handles pool sizing, queueing, task dispatch, retirement, all of that. But the worker-vs-process decision needs the same inputs either way i.e task size, startup cost, memory cost, message cost, and how failures happen.

Startup cost also hits cold paths. If a web request creates the first worker during the request, that request pays startup. If application boot creates workers before traffic comes, boot pays it. And if workers load big modules lazily on first task, the first task pays module loading even though the worker was already sitting there. So treat "is this worker actually ready" as application state, beyond just constructing the Worker object. Constructed and ready are not the same, believe me.

CPU core count also decides things. Two workers on a two-core machine can run CPU-heavy JavaScript in parallel, with the main thread competing for time also. Eight CPU-heavy workers on a two-core machine - now the threads outnumber the cores, and throughput may flatten or fall because threads are fighting for cores and caches. The right number comes from measurement, from what the task looks like, and from deployment limits. The worker API itself will accept any number the process can create, until resource limits push back. It will not stop you from making bad choices. That part is on you.

Memory budget is the same story. Every worker needs heap room. Resource limits can cap parts of the worker's V8 heap, sure, but the whole process still has one OS-level memory footprint. A container memory limit sees everything together i.e main thread memory, worker heaps, native allocations, buffers, stacks, shared memory - all of it in one bill. That single bill is itself a good reason to keep the worker count small, even before pool design starts.

And the best early benchmark is usually the boring one. Measure one task on the main thread. Measure one task in a cold worker. Measure several tasks through one warm worker. Track time taken, clone size, transferred bytes, process RSS, and event-loop delay in the main thread also. Then the numbers tell you what that is actually giving you - responsiveness, throughput, or neither one.

The event loop ref thing also comes in here. A referenced worker object keeps the parent process alive while the worker runs. ref() and unref() were covered earlier - worker objects expose the same kind of lifecycle control. Useful for optional background computation, risky for required work. A process that exits while optional workers are still running has chosen to abandon that work. It made a choice! Whether it knows it made that choice is a different question.

Shutdown handling should match the task. A fire-and-forget analytics calculation may use an unreferenced worker if losing the result during shutdown is acceptable. A paid report export should keep the process alive, or save the work somewhere safe like disk or a queue. Worker threads give you local parallelism only - background jobs that must survive restarts need storage and a different lifecycle setup. Different tools for different jobs, don't mix them up.

A Child Process Is Heavier And Cleaner

Child processes were the previous chapter's tool. They give Node a way to start another program - another Node through fork(), a direct executable through spawn(), or a shell command through exec().

A child process is a full OS process. It has a PID, its own address space, and when it's a Node child, its own V8 instance also. Its own module cache, environment, current working directory, stdio descriptors, signal behavior, process lifecycle - everything separate. The parent watches it through a ChildProcess object and whatever streams or IPC you configured.

A worker thread is something else - a thread inside one process. It gets its own isolate and its own event loop, but the PID and the process-level fate are shared. The parent watches it through a Worker object and message events.

So practically, compare them like this -

text
worker thread
  lower startup than a fresh Node child in many cases
  same process, separate isolate, messages between
  JavaScript-level shared memory through SharedArrayBuffer

child process
  separate OS process, separate address space, separate PID
  stdio, IPC channel, and handle passing
  stronger protection when a process-level failure comes

And let me be exact about the memory words here. Worker threads support sharing through SharedArrayBuffer, transferring ArrayBuffer backing stores, and cloning messages. Child processes talk through IPC serialization, plus handle passing through Node's IPC channel in supported cases. Ordinary child-process messaging gives you data copies or serialized values, and handles where supported. Each process keeps its own JavaScript heap, full stop.

Crash behavior is also different. This part is important, so listen.

If a worker throws an uncaught JavaScript exception, that worker terminates. The parent-side Worker emits 'error', then 'exit' with a non-zero code. If the parent handles that event, it can reject the task and make a replacement worker. But if the worker hits some process-wide fatal path, the whole process can die, because all workers share the process. One process-wide fatal and everyone goes down together.

If a child process crashes, the OS tears down that child only. The parent receives lifecycle events and can read the exit code or signal. Parent keeps running, because it's a different process. That is why child processes remain the right choice for tools with unstable native dependencies, or work that needs process-level crash safety. A child process is also the place to run some native executable you don't fully trust - but a separate process alone does not make that executable safe, ok. It stops crashes. It does not stop bad code from doing bad things.

Environment and startup state are also different. A worker inherits many options from the parent thread and starts inside the same process. A child process receives an environment object, argv, cwd, stdio setup, and executable path. That's why child processes are better for running external tools, applying a different PATH, dropping privileges, limiting inherited environment variables, or watching some command that has its own stdout and stderr setup.

File descriptors and sockets also point at child processes when the OS handle is the center of the design. Chapter 14 covered stdio and handle passing. Worker messages can transfer MessagePort, ArrayBuffer, and FileHandle in supported paths. Child-process IPC has its own supported handle-passing setup for server and socket handles. Pick the side that supports the handle type you need, that's the rule.

Watching from outside is one more reason. A supervisor can restart a child process, track a PID, put memory limits from outside, collect separate stdout and stderr streams, apply whatever policy the process manager has. A worker can be watched from the parent also, sure - but it lives inside the parent process. Parent goes down, which means workers also go down. They have no other home.

Security-sensitive work pushes the decision toward a child process plus explicit safety controls. A worker is good for trusted application code that needs CPU - it shares the deployment, the process, and many process-level capabilities. A child process can run with a reduced environment, different working directory, ignored stdio, lower privileges on supported platforms, and someone watching it from outside. But by default, mind you, a child inherits the parent's environment and OS credentials. A reduced env limits direct environment inheritance - it is not a sandbox. Untrusted code needs real privilege cutting and an OS sandbox, container, or something like that. Sandboxing has its own chapter later, but the local rule is already useful - how much you trust the code is part of choosing where it runs.

Logs and monitoring also look different. A worker failure shows up as events on the parent-side Worker object. Logs may need the worker's threadId and the task ID attached. CPU usage lives inside the same process unless you collect per-worker stats through worker APIs. A child process gives you stdout, stderr, PID, exit status, and process metrics from outside. Those outputs are heavier - and sometimes that is exactly what the ops side wants. Ops people love their PIDs.

Deployment also counts. A worker ships as JavaScript code inside the same package and Node process. A child process can be a separate executable with its own release cycle, its own system dependencies, and its own command line. If the workload is ffmpeg, ImageMagick, a Python model runner, or some CLI another team gave you - child process matches that. If the workload is a JavaScript parser or report generator sitting in the same codebase, a worker thread usually fits better.

So - workers are the right default for CPU-bound JavaScript you trust, sitting in the same package, and which can use the same-process memory options. Child processes are the right default for external programs, for work that needs its own process, for different OS-level state, and for stronger crash safety. And the main thread is still the right place for ordinary async Node work. The event loop is not going anywhere, relax.

A Few Real Decisions

A large JSON parse looks like a worker job at first, and honestly it is one - JSON.parse() is synchronous CPU work. If a request handler parses some huge string on the main thread, the event loop waits till parsing finishes. Nothing else moves. Move the parse into a worker and request handling stays responsive.

But the message crossing decides whether it's actually a win. If the main thread already has a 200 MB string and sends it to the worker, the send itself has to move that data across to the receiving isolate. And if the worker parses it into a huge object and sends the whole object back, the result path pays again. Better design - send a file path, parse inside the worker, return a small summary. Or transfer binary input and return compact structured output. The CPU work counts, but payload structure decides the cost. I have seen people "optimize" a parse and then pay twice in messages. Don't be those people.

A JavaScript source parser is a cleaner fit. Input is source text or a buffer. The worker parses, analyzes, returns a compact list of findings. Main thread stays responsive, and the worker owns the parser module state in its own module cache. If tasks repeat, a warm worker keeps that parser loaded also. Everyone is happy.

A password hash deserves some care though. Node's built-in crypto APIs already use native code, and for many async operations they run on libuv's worker pool itself. So moving a built-in async crypto call into a worker usually just adds a JavaScript thread around native async work - you gained nothing, congrats. A custom JavaScript password-hashing implementation is different, that code runs actual JavaScript CPU instructions and can fit a worker. The API tells you where the work really runs. Read the docs before you thread-ify things, is what I'm saying.

An image conversion through ffmpeg or ImageMagick points straight at a child process. The heavy work lives in an external executable. Parent needs argv, env, cwd, stdio, exit status, maybe a timeout. A worker thread here would still need to spawn that executable from inside the worker, which means you added a JavaScript thread around a process operation. Why. Just take the child process directly.

A report generator can go either way, depends. If the report is pure JavaScript over in-memory data and the output is a small file or compact result - worker thread is a good first option. If the report needs a headless browser process, external fonts, native plugins, or process-level resource caps - child process may be cleaner. And if the report must survive a parent restart, the answer leaves this chapter completely and goes toward work queues that survive restarts. Chapter 15 is not going with you, sorry.

A streaming proxy stays on the main thread with built-in async APIs. Network I/O, stream backpressure, agent pools, socket lifecycles - all this already belongs to Node's event loop way of working. A worker can help with some CPU-heavy transform in the middle, sure, but the socket work itself belongs in the main server path unless your whole architecture says otherwise. Don't get thread-happy.

All these examples come down to the same check -

text
CPU-heavy JavaScript, limited data     -> worker thread
external executable or own process     -> child process
ordinary async I/O work                -> main thread

That table is small only, because where the work runs is the whole decision. The runtime API follows from it.

Bad Choices Usually Start By Running Work In The Wrong Place

Creating a worker for every small operation turns startup overhead into latency. What you want instead - batching, reuse, or just staying on the main thread.

Creating a worker for I/O waits puts a message hop around an operation Node already knows how to wait for. Async I/O, connection-pool tuning, or concurrency limiting - that's what actually fixes it.

Using a worker where a child process is required just gives you another JS thread inside the same process. Take a child process when the workload needs separate OS memory, separate stdio, different environment state, or protection from process-level crashes.

Using a child process where a worker thread fits - now you pay process startup and IPC costs, and you also give up the built-in JavaScript shared-memory options. Use a worker when the work is trusted CPU-bound JavaScript, with inputs and outputs that cross the message path cleanly.

And the code should show clearly which side runs what -

js
if (isMainThread) {
  const worker = new Worker(__filename);
  worker.once('message', console.log);
  worker.once('error', console.error);
} else {
  const result = crunch(500_000_000);
  parentPort.postMessage(result);
}

You can see the line right there in the source. Parent work stays in the main thread, CPU-bound JavaScript runs in the worker, data crosses through messages. If that line needs to move, the design should change also. That's the real decision - choose where the work runs first, then choose the API that creates that. Everything else is details.