---
title: 'Child Processes'
source: 'https://academia.sh/en/courses/nodejs/child-processes'
course: 'The Node.js Runtime'
language: en
updated: '2026-08-17T18:09:50+00:00'
license: 'CC BY-SA 4.0'
---

# Child Processes

Two ways to run an external command, the risk brought by the shell layer, the distinction between an exit code and a terminating signal, and passing messages between runtime processes.

The previous lesson set up the service in a single process. What a single process can
do is limited, and some jobs it should not do at all: rewriting the work of a tool
that already exists on the system means trying to reproduce that tool's years of
tested behavior.

This lesson covers moving work outside the process. There are two separate needs:
running an external command, and starting a second process from the same runtime and
talking to it.

## Running an External Command

`spawn` in the `node:child_process` module starts a program as a separate process and
gives its three standard streams as an object. The stream model from the Shell
Programming course finds its exact counterpart here.

```js
// child-process.mjs
import { spawn } from 'node:child_process';
import { once } from 'node:events';

function run(command, args) {
  const child = spawn(command, args);      // no shell: args pass as an array
  const output = [];
  const errorOutput = [];
  child.stdout.on('data', (c) => output.push(c));
  child.stderr.on('data', (c) => errorOutput.push(c));
  return once(child, 'close').then(([code, signal]) => ({
    code,
    signal,
    output: Buffer.concat(output).toString('utf8').trim(),
    error: Buffer.concat(errorOutput).toString('utf8').trim(),
  }));
}

console.log('wc -l    :', await run('wc', ['-l', 'measurements.ndjson']));
console.log('missing  :', await run('cat', ['missing.txt']));
```

```sh
node child-process.mjs
```

```
wc -l    : { code: 0, signal: null, output: '12 measurements.ndjson', error: '' }
missing  : {
  code: 1,
  signal: null,
  output: '',
  error: 'cat: missing.txt: No such file or directory'
}
```

The difference between the two calls is in the exit code. Zero reports success, one
failure; the rule from the Shell Programming course has not changed. The diagnostic
message came from the second stream and did not get mixed with the data — this is the
runtime-side counterpart of that same distinction.

Chunks are collected as buffers and joined at the end. Converting each chunk to a
string separately and concatenating would have produced the boundary corruption shown
in the buffers lesson.

## The Shell Layer and Argument Passing

The same module has another function called `exec`. The difference is that it hands
the command to a shell process as text. The shell interprets that text with its own
rules: variable expansion, path expansion, pipelines, semicolon-separated commands.

This interpretation becomes a problem when a piece coming from outside is inserted
into the command text:

```js
// shell-injection.mjs
import { execFile, exec } from 'node:child_process';
import { promisify } from 'node:util';

const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);

// An untrusted file name coming from outside
const fileName = 'measurements.ndjson; echo INJECTED';

// 1. Call that inserts a shell: the text is interpreted by the shell
const { stdout: shellOutput } = await execAsync(`wc -l ${fileName}`);
console.log('exec     :', JSON.stringify(shellOutput.trim()));

// 2. Call without a shell: the name passes as a single argument
try {
  await execFileAsync('wc', ['-l', fileName]);
} catch (error) {
  console.log('execFile :', error.stderr.trim());
}
```

```sh
node shell-injection.mjs
```

```
exec     : "12 measurements.ndjson\nINJECTED"
execFile : wc: measurements.ndjson; echo INJECTED: open: No such file or directory
```

In the first call, the command after the semicolon also ran. Had the text substituted
for the file name been an account-deleting or data-exfiltrating command, the result
would have been run in the exact same way. In the second call, the entire text passed
as a single argument; `wc` looked for a file by that name and did not find one.

Rule: **do not build a command's text by string concatenation.** When the program
name and argument array are given separately, no shell sits in between and no text is
ever formed for interpretation. If a shell feature is genuinely needed — a pipeline
or redirection, say — a fixed command text is written and the variable parts are
passed as environment variables.

`promisify` is the built-in helper that converts a callback-style signature into a
promise-returning one. Most of the runtime's callback-based interfaces support this
conversion.

`exec` and `execFile` also accumulate output in memory and terminate the process
once it crosses a limit. `spawn` is used for commands producing large output, and the
output is processed through the stream.

## Exit Code and Terminating Signal

A child process can end in two different ways: exiting by its own request, or being
terminated by a signal. The `close` event reports these two cases in separate
fields.

```js
// signal.mjs
import { spawn } from 'node:child_process';
import { once } from 'node:events';

// A long-running job; we will not wait for it to finish on its own
const child = spawn('sleep', ['30']);
console.log('child started, separate process:', child.pid !== process.pid);

setTimeout(() => child.kill('SIGTERM'), 100);

const [code, signal] = await once(child, 'close');
console.log('exit code:', code, '| terminating signal:', signal);
```

```sh
node signal.mjs
```

```
child started, separate process: true
exit code: null | terminating signal: SIGTERM
```

A process terminated by a signal has no exit code; the field comes as `null`. A
check that looks only at the code cannot pass a `code === 0` test, but it could also
misread a `code !== 0` test. The correct check reads both fields together: success is
a code of zero **and** no signal.

The signal name itself was introduced in the Shell Programming course; `SIGTERM` is a
polite termination request, and the process can catch it and clean up. A forceful
terminating signal cannot be caught and gives no chance to clean up; for this reason
it is used only when the polite request goes unanswered. A process's own shutdown
behavior is the subject of the Process Management and Robustness lesson.

## A Second Process From the Same Runtime

The measurement collector's expensive work is its own code, not an external tool.
`fork` is used for this: it starts a new instance of the same runtime and sets up a
message channel between the two processes.

```js
// summary-child.mjs
import { readFile } from 'node:fs/promises';

process.on('message', async ({ file }) => {
  const text = await readFile(file, 'utf8');
  const buckets = new Map();
  for (const line of text.split('\n')) {
    if (line.trim() === '') continue;
    const { node, metric, value } = JSON.parse(line);
    const key = `${node}/${metric}`;
    const bucket = buckets.get(key) ?? { count: 0, total: 0 };
    bucket.count += 1;
    bucket.total += value;
    buckets.set(key, bucket);
  }
  process.send({ pidIsNumber: typeof process.pid === 'number', bucketCount: buckets.size });
  process.disconnect();
});
```

```js
// summary-main.mjs
import { fork } from 'node:child_process';
import { once } from 'node:events';

const child = fork('./summary-child.mjs');
console.log('separate process:', child.pid !== process.pid);

child.send({ file: 'measurements.ndjson' });
const [message] = await once(child, 'message');
console.log('from child:', message);

const [code] = await once(child, 'exit');
console.log('child exit code:', code);
```

```sh
node summary-main.mjs
```

```
separate process: true
from child: { pidIsNumber: true, bucketCount: 6 }
child exit code: 0
```

No memory is shared between the two processes. The value given to `send` is
serialized, passed through the channel, and reconstructed on the other side; only
serializable values can be carried — a function, a class instance, and a file
descriptor do not pass directly. This copying is a measurable cost for large data:
rather than sending millions of records to the child, sending the file name and
leaving the reading to it is preferred. The example above does exactly that.

The `disconnect` call closes the channel. As long as the channel stays open, the two
processes keep each other alive; if a child has finished its work but does not exit,
this is usually why.

Isolation has one more benefit: a crash in the child process does not bring down the
main process. The main process sees the `exit` event and makes the decision itself —
it can restart, answer the request with an error, or do the work itself.

## Summary

- `spawn` starts a program as a separate process and gives its three standard
  streams as an object; large output is processed through the stream.
- Running through a shell interprets the command text; when a piece coming from
  outside is inserted into that text, an extra command can run. The program name and
  argument array are given separately.
- The exit code and terminating signal are separate fields; a process terminated by
  a signal has no code, and a success check reads both fields together.
- `fork` starts a second instance of the same runtime and sets up a message channel;
  messages are copied through serialization, no memory is shared.
- Process isolation turns a child's crash into a decision the main process makes.

## Next Step

A child process is a way to move work out of the main thread, but a heavy one: each
process carries its own memory and its own runtime instance, and every message
between them is copied. The next lesson covers two alternatives — a set of processes
sharing the same port, and threads that can share memory within the same process —
and determines which fits which kind of work.
