Get E-Book
Child Processes & OS Workloads

Process Pools, Detached Processes, and Supervisors

Ishtmeet Singh @ishtms/June 11, 2026/39 min read
#nodejs#child-process#process-pool#detached#supervisor

Spawning ffmpeg for every request is fine... until starting the ffmpeg itself becomes half of what the user is waiting on. Then it's not so fine anymore, believe me.

The first version usually looks harmless only. Request comes in, parent starts some executable, connects the stdio, waits for it to exit, sends the response back. One request? Fine. Ten requests? Also fine. Then traffic goes up a bit and suddenly latency is jumping here and there, memory is climbing during bursts, some children are still alive even after the request that made them is fully gone, and one process is just sitting there forever because nobody is reading its stderr pipe. (If that pipe fills up, the child blocks on the write. Forever. You will discover this at 3 AM only, I promise.)

And the painful part is, every single spawn() call can be completely correct while all this is happening. Your code is fine! The problem is one level up i.e process lifecycle is now your problem at the application level.

So, process pool. Let's talk about it.

A process pool means the parent keeps a fixed number of child processes alive and keeps handing them work, instead of making a fresh process for every task. Those children are our workers. Worker gets some task, runs it, sends the result back, and then (this is the whole trick!) stays alive for the next one.

So is a pool some magic thing you should use everywhere? No, and I'm not going to pretend it is. What you get i.e process reuse, separate memory for each worker, crashes that only kill the one worker which crashed, a local queue, and one place in the parent to handle worker startup and shutdown. What you don't get is - durable jobs, retries that survive restarts, workers spread across machines. That's some other system's headache, and honestly it deserves its own guide. We are doing local work owned by this one parent process, and that itself is plenty for today.

Spawn Cost Changes the Design

Say we start one fresh process for every piece of work. What happens?

Node asks the OS to make the process, sets the environment and working directory you gave, configures the stdio, starts the executable, and connects IPC also if you asked for that.

With fork() there's even more setup, because now we are starting another Node. That poor child has to boot Node, initialize V8, load the worker module, initialize whatever that module loads also, and only then it can do something useful. Jeez. All this, per task.

A warm worker already did all of that. Task comes, parent sends a message, child runs it, sends the result back, and the same worker takes the next task after that.

So if your worker loads some big module, or initializes a native addon, or reads config files, or does anything costly at startup - process-per-task pays that cost every single time. A pool pays it once per worker, that's it.

IPC also goes the same way. fork() makes the IPC channel while making the child itself. If that child handles one task and dies, you built one full channel just to use it one time. In a pool, the same channel carries task after task after task.

Memory also changes, and in both directions. A reused worker keeps its heap, module cache, native allocations - so we stop redoing the startup allocation work, good. But state also sticks around longer now. Something leaks? It's just gonna keep leaking. Some library leaves weird process-local junk behind? Next task gets that same junk. (Yes, "weird process-local junk" is a technical term.) In practice this means long-lived workers need recycling eventually. We'll come to that.

What spawn() actually does

What happens inside a spawn is more than what the one spawn() call shows you. Node first validates the JS options and prepares the stdio setup, then it goes into the native child-process code and builds the libuv process options - executable path, arguments, environment variables, cwd, stdio config, uid, gid, flags, all that.

libuv tracks the spawned process with a uv_process_t. After uv_spawn() succeeds, that handle has the PID, and later it receives the exit callback when the child exits.

The stdio entries become instructions for each file descriptor. You asked for a pipe? libuv makes one, and Node gives you the parent end in JavaScript. You inherited some stream or fd? Child gets that handle. stdin, stdout or stderr is ignored? Node configures those descriptors accordingly before the child program starts running.

With fork(), IPC is just one more stdio entry. Node starts another Node process with that extra channel, and sets up enough runtime state inside the child that process.send() and 'message' work.

And don't fool yourself - it's still a fully separate process. Separate PID, separate V8 heap, separate event loop, its own module cache and native state. Parent and child just happen to be talking, that's all.

One PID thing people mess up - the numeric PID identifies that process only while it's alive, which means after the process exits, the OS can give that same number to some totally different process later. So don't keep old PIDs lying around in a table and then act on them at some random future time - you might end up SIGKILLing your database or something. (This has happened to people. Not to me. Moving on.) Tie lifecycle operations to the current ChildProcess object and the worker record that owns it.

Per-task, concretely

A per-task launch can be as small as this -

js
const { spawn } = require('node:child_process');

function thumbnail(input, output) {
  return spawn('magick', [input, '-resize', '640x', output], {
    stdio: ['ignore', 'ignore', 'pipe'],
  });
}

One image task makes one OS process. We're ignoring stdin and stdout because this command doesn't need them. stderr stays piped because if ImageMagick fails, we would like to know why, no?

For occasional work this is completely fine. It even has one nice property - one process belongs to one task, so failure handling stays simple.

The problem starts when you do this hundreds or thousands of times. Now spawning itself is constant work, which means parent has to track so many child processes, drain output from every one, deal with timeouts, cancellations, exits, PIDs coming and going. Too much.

A pool changes that -

js
const { fork } = require('node:child_process');
const path = require('node:path');

const workers = Array.from({ length: 4 }, (_, id) => ({
  id,
  child: fork(path.join(__dirname, 'pool-worker.js')),
  busy: false,
}));

Now the parent owns four long-running Node children. Each one has its own memory and V8 instance, so if worker 2 dies, workers 1, 3, 4 and the parent don't even care.

Of course, four Node workers also means four Node processes worth of memory - four heaps, four module caches, four event loops, four sets of native allocations, you get it. So don't make a pool because "pools are better". Make one when avoiding the repeated startup work and keeping process isolation is worth that memory for you. Sometimes it's not worth it! You'll live.

The Pool Itself

Once we have workers, parent has to decide who gets each task.

A worker usually needs only a few states - idle, running some task, or being replaced because it exited, timed out, or we decided to recycle it. And the parent normally owns one queue - just tasks waiting for some worker to become free.

And I mean queue in local memory only. Parent dies, queue also dies. For request-scoped local execution, that's often exactly what you want. If you need the queue to survive process restarts, you are not building this kind of pool anymore - now you're building something with a database inside it, and that's a different guide altogether.

Dispatch code can stay small -

js
function dispatch(task) {
  const worker = workers.find(w => !w.busy);

  if (!worker) {
    queue.push(task);
    return;
  }

  worker.busy = true;
  worker.task = task;

  worker.child.send({
    type: 'run',
    task,
  });
}

Parent looks for an idle worker. Everyone busy? Task goes in the queue. Someone free? We note down which task belongs to that worker and send it over IPC.

Worker side is also small -

js
process.on('message', async ({ type, task }) => {
  if (type !== 'run') return;

  const result = await runTask(task);

  process.send({
    type: 'done',
    id: task.id,
    result,
  });
});

Worker receives run, runs the task, sends done. Real code should send failures also, obviously - if runTask() throws, the parent has to know somehow, otherwise it will keep sitting there waiting for a result that is never coming.

Then on the parent side -

js
child.on('message', msg => {
  const worker = byPid.get(child.pid);

  worker.busy = false;
  finish(worker.task, msg);
  worker.task = null;

  drainQueue();
});

One detail - mark the worker free before calling drainQueue(), because drainQueue() might immediately give this same worker the next task, and a worker still marked busy will get skipped. Order matters here.

After some time the protocol grows a bit. Child sends ready after startup. Parent sends run. Child sends done or fail. Parent can send shutdown when it's draining workers. You don't need some huge protocol for all this - small named messages with task IDs will take you very far.

Task IDs become really useful once timeouts and recycling exist. Suppose task 41 times out. Parent marks it failed and decides this worker should be replaced. One moment later the child, still happily working with no idea what happened, goes and sends this -

js
{
  type: 'done',
  id: 41,
  result: ...
}

Too late! Without the task ID and active-task state, parent might treat that as a valid result for something already finished. With both, parent can see task 41 is already done from its side and just ignore the late message.

Same for errors. I would not try sending a raw Error object and hoping both sides interpret it exactly the way I wanted. Send plain data only -

js
{
  type: 'fail',
  id: task.id,
  error: {
    name: err.name,
    message: err.message,
    code: err.code,
    stack: err.stack,
  }
}

Maybe you skip the stack in some environments, but you got the point. Both sides should agree on the message format, then nobody gets surprises.

ready can also carry useful startup info -

js
{
  type: 'ready',
  pid: process.pid,
  version: WORKER_VERSION,
}

You can even send supported task names or startup diagnostics if you need. Then parent can reject some wrong worker version before giving it real work. Politely or rudely, your choice.

The Parent Has Two Kinds of State

A pool worker has two things in it - the actual ChildProcess object, and your application bookkeeping around it. And these two are not the same, please don't mix them.

The ChildProcess object tells you what Node knows - pid, stdio streams, IPC methods, lifecycle events like 'exit' and 'close'.

Your worker record has what you know, and Node knows nothing about it - current task ID, timeout timer, Promise resolve/reject functions, whether cancellation happened, whether we're replacing this worker, maybe how many tasks it has handled till now.

Lots of annoying pool bugs happen when these two get mixed up. Child can emit 'exit' before stdout and stderr fully close. IPC message can arrive before 'close'. Timeout can fire few milliseconds after the child already sent a successful result. .send() can return false because Node's IPC backlog crossed its flow-control threshold. Process can disappear while a .send() callback is still pending. And any of these can happen at the same time as any other one. Races everywhere.

So the parent needs exactly one place that can finish a task. Result message may settle it, or timeout, or child exit, or cancellation - whichever comes first, that one wins. Everything after that sees "this task is already done" and only cleans up whatever state is left.

Even this much helps -

js
function settle(task, value, isError) {
  if (task.done) return;

  task.done = true;
  clearTimeout(task.timer);

  if (isError) {
    task.reject(value);
  } else {
    task.resolve(value);
  }
}

Now if a timeout fires after the result already came back - nothing happens. Late IPC message after timeout - nothing. 'close' running after we already rejected on 'exit' - again nothing. One boolean flag and all these races just quietly go away. My favorite type of bug fix, honestly.

And even if each worker runs one task at a time only, still put task IDs in the IPC messages. A worker can send logs, progress messages, health data, startup data, final results - all on the same channel. Parent should always know which final result belongs to which active task. Ask me how I know.

Worker Readiness

fork() returning does not mean your worker finished startup, okay? The process exists, yes. But its module may still be loading. Maybe it's importing some big package, opening a local resource, initializing a native dependency, loading configuration, whatever.

For small workers you might never even notice, because startup is fast and IPC messages can queue up and wait.

For heavier workers, I'd rather have the child itself tell the parent when it's ready -

js
process.send({ type: 'ready' });

process.on('message', msg => {
  if (msg.type === 'run') {
    runOne(msg.task);
  }
});

Then the parent doesn't mark that worker idle until ready comes. Before ready, child process exists but it is not available for work yet. After ready only, we can dispatch tasks to it.

Simple thing, but so much less guessing.

Don't Forget stdout and stderr

Long-running workers also need a permanent stdout/stderr policy. Meaning if you configured pipes, somebody has to keep reading those pipes, always.

Otherwise a worker that writes enough output can eventually just stop, because the pipe buffer fills up and its next write can't continue yet. From the parent side this looks very confusing - your "task is hanging", you are debugging your task logic, and the child is actually stuck trying to write one log line. (You will stare at completely wrong code for this one. Everyone does.)

So if workers have piped output, drain it. Send it to your logger, forward it somewhere, discard it deliberately - whatever your setup needs. Just don't accidentally make a pipe and then never read from it.

IPC Payloads Should Stay Reasonable

For Node workers, IPC is nice for task metadata and smaller results -

js
worker.child.send({
  type: 'run',
  task: {
    id: 42,
    input: '/tmp/input.mp4',
    output: '/tmp/output.mp4',
  },
});

That's fine.

But sending a 400 MB video through process.send() because "we already have IPC anyway" - no. Just no.

That data has to be serialized and moved between processes. You're spending CPU on serialization, memory on both sides, and putting pressure on the IPC channel also.

For big binary data, file paths, streams, shared external storage, some other transfer method - usually any of these makes more sense. Node's advanced child-process serialization can support more JavaScript types than plain JSON serialization, sure, but we are still serializing across process memory. Separate process means separate memory, no getting around it.

Warm Workers Also Mean Old State Can Stay Around

One nice thing about warm workers - they can keep useful local state. Maybe a worker keeps some parsed config around. Maybe it keeps a native library initialized. Maybe it loads a model once instead of once per task. Maybe it maintains a local cache. All good.

But the exact same reuse also means stale or broken state sticks around. Memory leaks accumulate, native allocations stay alive, some library slowly increases memory after every task. Config loaded during startup becomes old. One especially bad task might leave the process-local state in some condition you don't trust anymore.

So a real pool normally has some worker recycling rule. Maybe replace a worker after 1,000 tasks, or when RSS crosses some threshold, or after one fatal task error, or when the config version changes. What you choose depends on what that worker actually keeps inside it.

And replacement should happen through one controlled path only. Stop giving the worker new tasks first. If it currently has a task, either let that task finish or give it a deadline. Once it's done, disconnect the IPC so the worker can exit normally. It doesn't exit before the deadline? Send a signal. Then wait for 'close', make a fresh child, wait for its ready message, and only then put the new worker back into the idle set.

Doing replacement through one path also gives you a good place for logs and metrics. You can record why the worker was replaced, how many tasks it handled, its memory before exit, how long startup took, queue size while it was unavailable - all that.

And that's more or less the pool model, really. Parent owns the workers, the queue and the task promises. Workers run one task and report back. Task settles once, worker has a known lifecycle, and old workers get replaced eventually.

Once you have these pieces, a child-process pool stops being "some array of fork() calls" and becomes something you can actually run under load, without wondering which PID is still alive and why.

Saturation and Bounded Queues

Pool saturation just means all your workers are busy and more work is waiting in the queue. That's normal, don't panic. Four workers and five tasks arrive at the same time? Well, somebody has to wait.

The problem starts when you keep accepting more and more work with no limit.

Say tasks are coming in faster than your workers can finish them. Queue starts growing. For a short traffic burst, some queue space is actually useful, because workers can catch up after the burst is over. But if there's no upper limit, that queue can just keep growing in memory while callers wait longer and longer.

And from outside, your process can look totally fine for quite some time, because it's still accepting every request. Nothing is getting rejected, so you might think everything is okay. Meanwhile there are hundreds or thousands of tasks waiting, response time keeps increasing, cancelling work becomes annoying, and shutdown now has this giant pile of pending work to deal with.

So yeah, put a limit on the queue -

js
function enqueue(task) {
  if (queue.length >= 100) {
    throw new Error('process pool saturated');
  }

  queue.push(task);
  drainQueue();
}

Here the parent accepts up to 100 waiting tasks. Once the queue reaches that number, new work gets rejected.

What you do with that rejection depends on who called the pool. An HTTP route might return 503 Service Unavailable. A CLI command might just fail. Some internal function might reject a Promise with a custom error, so the caller can decide what to do next.

And that 100 is not some Node.js magic number, please. You choose it based on how much waiting your application can tolerate.

Suppose one task takes around 500ms and you've got four workers. If another 100 tasks are already sitting in the queue, a newly accepted task can wait quite a few seconds before any worker even starts it. Maybe that's completely okay for some admin batch process. For an HTTP request where some person is sitting there waiting for a response? Probably not.

So queue size is really stored waiting time. A bigger queue lets you accept more temporary load, but you're also agreeing that some callers may sit around longer before their work even begins. Choose accordingly, simple as that.

Backpressure in a process pool

You may already know stream backpressure (assuming you've gone through the streams chapter).

With streams, producer might call write(), get false back, stop producing for some time, and continue after 'drain'. Standard stuff.

A process pool usually doesn't work through write() and 'drain'. Callers might be using Promises, callbacks, HTTP requests, IPC messages, whatever. So the pool has to make its own admission decision when work arrives.

Do we accept this task? Do we reject it? Has the caller already cancelled? Should some older queued task be removed because this newer one replaces it?

These are decisions your pool has to make before the task reaches any worker. And try to make that decision before doing expensive setup around the task also.

For example - say an HTTP endpoint accepts some massive upload and then sends the uploaded file to a worker for thumbnail generation. If the pool is already full, reading the entire upload into memory first and only then discovering you can't enqueue the thumbnail task is pretty wasteful, no? You just pulled all those MBs into RAM for nothing.

If your protocol and request flow allow you to check capacity earlier, check it earlier. Simple as that.

Same with CLI batch work. If somebody runs a command against ten thousand files, don't create ten thousand pending Promises immediately and hope the pool sorts it out somehow. Feed tasks into the pool gradually and keep the amount of waiting work capped.

Deadlines can start while the task is still queued

Another useful thing - attach the caller deadline when the task enters the queue, instead of starting the timer only after a worker picks it up.

Suppose the caller gives you two seconds to finish, but looking at the current queue delay you already know the task probably won't start for three seconds. Now what is the point of sending that task to a worker later? The caller is already gone by then. The worker would be computing a result nobody will ever read.

So you can fail the task while it's still waiting, and save the worker time for jobs where somebody is actually sitting there waiting for the result.

This also gives better error reporting. If a task spent four seconds waiting in the queue and then you report "worker timeout", that's a bit misleading only, because the worker wasn't doing anything for those first four seconds. Queue wait and worker runtime should be measured separately.

Different applications will want different queue rules also. Some just reject new work when the queue is full. Some remove older queued work when newer work replaces it. Maybe you only want one pending thumbnail task per asset, because generating the same thumbnail six times is pointless. Maybe tasks disappear from the queue as soon as their caller deadline expires.

The pool can provide counters and hooks for these cases, but your application only has to decide what behaviour actually makes sense.

Cancellation

Queued work is the easy case.

If the caller cancels before the task reaches a worker, remove the task from the queue and settle whatever Promise or callback belongs to it. Done.

Active work is more annoying, because now the child is already doing something.

You can have a cancellation message in your worker protocol. Parent sends something like -

js
child.send({
  type: 'cancel',
  taskId
});

and the worker stops that particular task if it knows how.

Or the parent can just terminate the whole worker process.

Killing the process is much harsher, because you also throw away everything else stored inside that worker's process memory. But sometimes that's exactly what you want also, especially if you're running code or executables that you don't fully trust to stop cleanly. (Some programs say "sure, stopping" and then keep running anyway. You know the type.)

For workers you plan to reuse, cooperative cancellation is usually nicer, when the underlying task can actually support it.

You need numbers from the queue

Once you have a pool in production, "it feels slow" isn't enough information.

You want to know how many workers exist, how many are busy right now, how many tasks are waiting, how many tasks got rejected, how long tasks wait before starting, how long they actually run, and how workers are exiting.

Those numbers tell you very different things.

Maybe the queue is growing because traffic increased. Maybe traffic stayed flat but one task that normally took 100ms now takes two seconds. Maybe some external program your workers call has started hanging. Maybe workers are crashing and the pool is quietly running with half its normal capacity.

Without the queue and worker numbers, all of those can look like the same symptom i.e requests got slow.

Failure Containment and Recycling

One big reason to put work in another process is that the worker has separate process memory from the parent.

If some native library segfaults, an external executable exits badly, or the worker gets into such a bad state that the process dies, your parent can stay alive and observe what happened. This is the whole reason we're here.

If a worker exits while it's processing a task, the parent should fail that task.

js
child.on('close', (code, signal) => {
  const worker = byPid.get(child.pid);

  if (worker.task) {
    fail(worker.task, { code, signal });
  }

  replace(worker);
});

The 'close' event happens after the child has exited and its stdio streams have closed, so it's a good place to finish the parent-side cleanup.

If there was an active task, fail it with the exit information. Then start replacing that worker.

A worker dying while idle also deserves attention.

There may be no caller task to fail, but a warm process just disappeared for some reason. Replace it and record the exit. Don't just shrug.

And if that keeps happening again and again, don't blindly fork another worker forever.

Imagine a worker has a startup bug and crashes instantly. Parent forks it, it crashes, parent forks again, it crashes again, parent forks again... congratulations, you built yourself a CPU-burning log generator. (The logs will be impressive. The CPU graph also.)

Track recent exits. If replacements keep dying within a short period, slow down the replacement attempts and mark the pool as unhealthy or degraded.

Task timeout and worker timeout aren't exactly the same thing

A worker process can stay alive forever while the task inside it is stuck. Process is fine, task is dead. Very sneaky.

So timeouts should usually be attached to tasks.

js
function armTimeout(worker, ms) {
  worker.timer = setTimeout(() => {
    fail(worker.task, new Error('worker timeout'));
    worker.child.kill('SIGTERM');
    worker.replacing = true;
  }, ms);
}

When the timer fires, the parent fails the active task and asks the child to terminate.

I wouldn't do all the replacement cleanup inside that timeout callback though. Let the normal child 'close' handling finish the worker lifecycle. That way exit code, signal information, stdio cleanup, task cleanup, replacement - everything goes through the same code path. Much easier to reason about, believe me.

Sometimes a worker should be retired even after successful work

A worker doesn't need to crash before you replace it. Memory growth is the common example.

Maybe you're using some native library that slowly increases RSS after processing thousands of files. Tasks still complete successfully, all green, but each worker keeps getting larger and larger.

You can have the worker report memory usage periodically, or inspect it from the parent when your platform gives you a way to do that. Once a worker crosses whatever limit you've chosen, stop assigning new work to it. Let the current task finish, then shut it down and create a fresh worker in that slot.

You also don't want every task error killing the worker.

Bad user input? Unsupported file format? Validation error? Some external command returned a known failure code? Those can usually fail just the task, worker keeps living.

But if the worker sends a response that breaks your IPC protocol, or returns malformed data that should never be possible, or times out, or reports damaged internal state, or starts giving repeated unknown failures - I'd be much less interested in reusing that process. Fail the task, retire the worker, start another one.

The whole reason you're using another process is that you can throw away that process state when you stop trusting it. So use that ability!

Reused workers make listener mistakes more obvious

Say every worker writes logs to stderr. Attach the stderr listener once, when you create the worker.

Don't attach another listener every time you assign a task. Please.

A worker might process tens of thousands of tasks during its lifetime. If every task adds another listener and you forget to remove it, eventually every stderr chunk gets processed by a pile of old handlers. Then Node starts warning about too many listeners and your logs become complete nonsense.

Keep long-lived process listeners attached for the worker lifetime, and include the current task ID in whatever logging context you're maintaining.

Worker replacement needs some restraint

If one worker crashes, sure, replacing it immediately is usually fine.

If four workers crash within the same second, blindly replacing all four may just repeat whatever caused them to crash in the first place.

A small supervisor can track recent exits for each worker slot and for the pool overall. After repeated failures, add some delay before replacement and report that the pool is degraded.

And if every worker is gone, don't keep accepting tasks into a queue that nobody can process. Either reject them immediately, or keep the pool closed until at least one replacement worker reaches its ready state.

I also prefer keeping a logical worker slot separate from the actual ChildProcess object.

Say you configured four workers. So you have slot 0, slot 1, slot 2, slot 3.

Slot 2 might originally contain PID 4101. That process crashes, so you replace it with PID 4188. Child changed, but the logical slot stayed the same only.

This gives you useful per-slot history also. If every slot is crashing, that's probably one kind of problem. If only one slot keeps dying, maybe that slot gets some different config or bad startup data.

And planned replacement should be recorded differently from crash replacement.

If you intentionally stop assigning work to a worker, wait for its active task to finish, send shutdown, let it exit - that's a normal recycle.

If the child disappears while busy or idle without you asking it to - that's a crash.

Those should not end up in the same metric, because they tell you very different things.

Detached Children

A detached child is a process you start with the intention that it may keep running after the parent exits.

In Node, the main option you'll see is -

js
detached: true

What exactly that does depends on the operating system.

On POSIX systems, Node starts the child as leader of a new process group and a new session.

You don't need to become some Unix process-control historian to use this, but the terms are useful.

A process group is an OS grouping used for things such as sending signals to multiple related processes and terminal job control. A session can contain one or more process groups and can have a controlling terminal.

The POSIX call involved here is setsid(). It creates a new session, and the calling process becomes the session leader and process-group leader also.

Node exposes the behaviour through detached.

js
const { spawn } = require('node:child_process');

const child = spawn(process.execPath, ['worker.js'], {
  detached: true,
  stdio: 'ignore',
});

child.unref();

There are actually a few separate things happening in that example.

detached: true changes the child's OS process setup.

stdio: 'ignore' stops the child from depending on the parent's terminal streams.

And child.unref() tells Node that this ChildProcess handle should no longer keep the parent's event loop alive by itself.

With those pieces in place, the parent can exit and the child can continue living. Feels wrong the first time you see it, but it's exactly what we asked for.

unref() does less than people sometimes assume

child.unref() doesn't somehow cut every connection between parent and child.

It changes whether that child handle counts as active work that keeps the parent process alive. That's it. Full feature list.

Signals still work according to the OS rules. Stdio still behaves however you configured it. IPC still has its own handles and references. The child still has its own lifetime.

So if you forked a child with an IPC channel and leave that channel active, you can still have something keeping one side alive even though you called unref() somewhere else. Annoying? Yes. Surprise? Shouldn't be, not anymore.

You need to look at every open handle, not only the ChildProcess object.

Detached stdio needs some thought too

Suppose you detach a long-running child but leave stdout and stderr inherited from the parent terminal.

Now the process lifetime may be independent, but its output is still connected to that same terminal setup. Half independent, half not.

For long-running detached work, you'll usually either ignore stdio or redirect it somewhere that will still exist after the parent goes away.

For example -

js
const { openSync } = require('node:fs');
const { spawn } = require('node:child_process');

const out = openSync('./worker.log', 'a');
const err = openSync('./worker.err', 'a');

const child = spawn(process.execPath, ['worker.js'], {
  detached: true,
  stdio: ['ignore', out, err],
});

child.unref();

Now stdin is ignored and the child writes stdout and stderr to files.

The parent opened those file descriptors though, so after spawn() has duplicated what the child needs, production code should close the parent's copies when it's done with them. Otherwise you're leaking fds, and that's its own separate bad day.

POSIX process groups

On POSIX, when you start a detached child, its PID is also the ID of the new process group created for it. Convenient!

That gives you a useful option if the child later starts more processes also.

You can signal the whole group with a negative PID -

js
if (process.platform !== 'win32') {
  process.kill(-child.pid, 'SIGTERM');
} else {
  child.kill('SIGTERM');
}

On POSIX, -child.pid means the process group with that ID.

So if the detached child starts its own children and they remain in that process group, one group signal can reach all of them, assuming the sending process has permission.

This is POSIX behaviour though. Don't copy negative-PID signal code into Windows paths and expect the same thing. It will not be the same thing.

And don't keep some old PID around forever and signal it hours later also. PIDs can be reused after processes exit. Keep signal handling tied to the current live child state you actually know about.

OS process lifetime and Node event-loop lifetime are separate

This is probably the cleanest way to understand detached children.

One question is - how has the operating system grouped this process, and what happens to signals and terminal relationships?

The other question is - does Node still have some referenced handle that keeps the parent event loop alive?

detached: true deals with the first question. unref() deals with the second.

And other things can still keep the parent alive anyway. A timer can. A server can. An open stream can. IPC can.

That's why examples for truly independent child work usually configure the process as detached, give it independent stdio, and call unref(). All three pieces, not one.

Detached work needs somebody to own it

This is the part people skip. Every time.

If the parent launches a child and then stops waiting for it, who owns that child now?

Where does it write logs? How do you know whether it completed? How do you stop it? What happens if it gets stuck?

For a local dev tool, maybe a PID file and log file are enough. Fine.

For server work, you often want some other process manager or durable job system to own that lifetime instead.

And if the child needs to report progress after the parent exits, IPC to the old parent obviously isn't much use anymore. You need some other output path whose lifetime is independent also - maybe a file, a local socket, a database record, or another service.

Detached children give you local process lifetime control. They don't automatically give you durable jobs, restart after reboot, persistent task state, leases, scheduling, or deployment health checks.

Those are separate systems. Somebody has to build them, and it's not gonna be this chapter.

Orphans, Zombies, and Reaping

These terms sound slightly dramatic the first time you see them, but the ideas aren't too bad.

An orphan is a child process whose parent has exited while the child is still running.

The OS gives that child a new parent. On Linux, a process configured as a subreaper may adopt orphaned descendants. Otherwise PID 1 usually ends up taking that job. Other POSIX systems have their own process adoption rules.

A zombie is different.

A zombie has already finished running. The process is dead, but the kernel keeps a small record containing its exit information, because some parent still needs to collect that status.

On POSIX systems, that collection happens through calls such as wait() or waitpid(). Collecting that exit status is called reaping. Once the status has been collected, the kernel can remove the remaining process-table entry.

In normal Node.js code, you don't sit there manually running some waitpid() loop. Node and libuv handle that part and expose child lifecycle through events such as 'exit' and 'close'. Thank you, libuv.

So from application code, the practical rule is pretty simple - if your parent stays alive and launches children, listen for child completion and clean up your own task state when they exit. Read the code or signal. Settle the task. Clear timers. Update worker state. Replace the worker if that's what your pool does.

If your parent itself exits, then those running children move into the operating system's adoption rules. Not your problem anymore, in the nicest possible way.

A zombie in a process listing is sometimes misunderstood as a process that's still doing work. It isn't! Execution is already finished. What's left is just the process status waiting to be collected.

If you're somehow seeing long-lived zombie children associated with a Node program, that's unusual enough that I'd start checking native addons, embedding setups, strange signal handling, or a parent process that isn't progressing through its normal child lifecycle handling.

Orphans are different because they can keep running.

A detached child can become an orphan intentionally after its launcher exits. That's okay, but only if you already planned for the child to have an independent lifetime and somebody still knows how to observe and stop it.

Local Supervisors

Once you have several workers, somebody in the parent process needs to manage all of them.

That's the supervisor.

In this chapter I'm talking about supervisor code you write inside your Node application - not systemd, not Kubernetes, not PM2, not some deployment-level process manager. Your own code.

For a worker pool, the supervisor starts the workers, waits for them to become ready, sends them tasks, limits the queue, handles timeouts, deals with worker exits, creates replacements, drains workers during shutdown, and exposes pool state.

There's quite a bit there, which is why having explicit state helps.

I normally want separate data for overall pool state, logical worker slots, queued tasks, and currently active tasks.

The pool state tells me things such as - whether new work is being accepted, whether shutdown draining has started, whether the pool is degraded, or whether it's closed completely.

Each worker slot tells me which child process currently occupies that slot and whether it's starting, ready, busy, draining, replacing, or gone.

The queue contains accepted tasks that haven't been assigned yet.

The active-task table connects a caller's task record to the worker currently processing it.

Having these separate makes a lot of operations less confusing.

Starting shutdown means change pool state first, so new work stops entering. Replacing a child means update one worker slot. A worker crash means fail whichever active task points at it. A cancelled queued task means remove one queue entry without touching workers at all.

You can also keep the same task ID and slot ID in logs throughout all of this, which becomes extremely useful once multiple workers are running at the same time and everything looks the same otherwise.

Startup should be supervised too

Don't assume a worker is ready just because fork() returned a ChildProcess. It returned an object, not a promise.

Maybe the worker still has to load some huge module, connect to something local, warm some data, start an executable, or initialize native code.

Have the child send a ready message when initialization has actually completed.

The parent can start all configured workers and wait until enough of them report ready before the pool starts accepting normal work.

And give startup a deadline also.

If two workers never reach ready, you want an error saying which workers failed startup - not quietly accepting tasks into a pool that doesn't have the capacity you thought it had. That's a bad morning right there.

Report pool state, not just worker count

A status endpoint that only says "workers": 4 isn't telling you very much.

Four workers could mean four idle workers, four workers stuck for twenty minutes, or four process objects that haven't even finished starting. Same number, very different days.

You want to know whether the pool is accepting work, draining, or closed. You want configured worker count, ready count, busy count, queued task count, how old the oldest queued task is, recent worker exits, and how many tasks were rejected because the queue was full.

Now you can tell the difference between a full queue and a slow worker. You can tell whether the pool is degraded because workers keep crashing. You can tell whether requests are failing because shutdown has already started.

All of those can produce failed work, but for completely different reasons.

The supervisor can live inside the same Node process as your HTTP server, or you can put it in another local parent process and have the server talk to it over IPC or a local socket.

If HTTP handling and worker supervision live together, shutdown is simpler, because both pieces share state directly.

If you put them in separate processes, you get stronger process isolation, but now you also need a proper communication protocol between them. So there's more machinery. Your choice.

Shutdown is part of the supervisor design

Shutdown usually exposes whether your worker pool design is actually coherent. If it's not, shutdown is where you find out.

Say the parent receives SIGTERM. First, stop accepting new work into the pool.

Then decide what happens to tasks already waiting. Maybe reject them immediately. Maybe cancel them. Maybe your application allows them to drain. All valid, but decide before you need it.

Let active tasks finish up to some deadline.

Once the deadline passes, signal workers that are still running tasks.

Then wait for child 'close' events, so each process goes through the normal cleanup path before the parent exits.

The worker needs its side of this too.

js
process.on('message', msg => {
  if (msg.type === 'shutdown') {
    draining = true;
  }
});

process.on('disconnect', () => {
  if (activeTask) return;
  process.exit(0);
});

Once the worker receives the shutdown message, don't assign it new tasks.

If IPC disconnects while the worker is idle, it can exit cleanly.

If it's still running something, the parent remains responsible for enforcing whatever shutdown deadline you've chosen. Somebody has to enforce it, and that somebody is the parent.

You don't need a giant restart system inside this pool supervisor either.

Replace a worker when it crashes. If workers keep crashing repeatedly, slow down replacement attempts. Report clearly when the pool can't maintain its configured worker count.

Machine restart behaviour, deployment recovery, service-manager policies - that belongs to higher-level tooling. Let them do their job.

Observability belongs in the parent

The parent sees the whole pool, so it's the best place to collect pool-wide numbers.

You should be able to answer basic questions without guessing.

How many workers are alive right now? How many are ready? How many are busy? How long are tasks waiting? How long do tasks take once a worker starts them? How many tasks have been rejected? Which workers restarted recently? Did they exit normally, with an exit code, or because of a signal?

If you can't answer those questions, a process pool tends to turn every slowdown into "Node is randomly slow today", which isn't very useful for anyone.

Choosing the Right Process Split

So when should you actually use a child-process pool?

Use one when you specifically want work to live in separate process memory, when you're calling external executables, when native code can crash, or when being able to terminate the whole worker after a bad task is useful.

Image conversion fits well. Media probing through tools such as ffprobe fits well. Running command-line tools fits well. Native libraries that occasionally crash or leave process-local state in a bad condition can also be a good reason.

If you've got CPU-heavy JavaScript and you mainly want parallel JavaScript execution without the memory cost of several full Node processes, worker threads may be a better option. We'll cover those separately, don't worry.

If the task has to survive the parent process exiting, a deploy restarting the app, the machine disappearing, or the original HTTP caller disconnecting - now you're asking for something more than a local process pool.

You probably want externally stored job state with acknowledgement, retries, ownership, and recovery. That's job-queue territory, and again - different guide.

Detached children sit somewhere else again. They're useful when a local child genuinely needs to continue after the launcher exits, and you've already given it independent output and cleanup handling.

Starting a detached process from an HTTP request and then forgetting about it is something I'd be very careful with. The request can finish while that child keeps modifying files, running commands, changing other state. If your application explicitly allows that, fine. But don't accidentally get there because detached: true looked convenient that day.

And finally, the local supervisor ties the whole process-pool setup together.

It makes overload visible instead of letting the queue grow forever. It converts worker exits into clear task failures. It replaces bad worker processes with fresh ones. It gives startup and shutdown one consistent path. And it gives you enough state to tell whether the pool is healthy, full, draining, or falling apart.

Without that parent-side supervision, you've really just spawned a bunch of child processes and hoped they behave. Hoping is not a strategy, my friend.