stdio Configuration and Piping
Before a child process gets to run even one line of its own code, its standard descriptors are already waiting for it. File descriptor 0 is standard input, 1 is standard output, and 2 is standard error. The stdio option tells Node where each one should lead.
From inside the child, those three descriptors show up as the familiar process.stdin, process.stdout, and process.stderr. The parent sees the other ends on the returned ChildProcess object, but only when you asked Node to create pipes for them.
For the usual arrangements, stdio can be a string. When you need to choose each descriptor separately, use an array. Its positions matter: index 0 belongs to the child's fd 0, index 1 to fd 1, and index 2 to fd 2. Keep adding entries and you can configure fd 3, fd 4, and beyond.
The three string forms are simply convenient shortcuts:
'pipe' -> ['pipe', 'pipe', 'pipe']
'ignore' -> ['ignore', 'ignore', 'ignore']
'inherit' -> ['inherit', 'inherit', 'inherit']Arrays are where you can mix and match. For example, ['ignore', 'pipe', 'inherit'] throws away stdin, pipes stdout back to the parent, and sends stderr to the same place as the parent's stderr. A null or undefined entry uses that position's default: a pipe for the first three slots in spawn(), and an ignored descriptor after that.
One small but important detail: an extra descriptor does not spring into existence merely because its number is available. The child receives fd 3 only if the array has something at index 3.
With no stdio option at all, spawn() creates three pipes.
import { spawn } from 'node:child_process';
const child = spawn(process.execPath, ['-e', 'process.stdout.write("hi\\n")']);
child.stdout.pipe(process.stdout);The parent ends of those pipes are child.stdin, child.stdout, and child.stderr. Meanwhile, the child sees nothing unusual: just fd 0, 1, and 2 serving as its standard streams.
You can capture output with exec() and execFile() as well, but those APIs collect everything in a buffer and hand it over at the end through a callback or promise. We will get to maxBuffer and the wider buffering story in Chapter 14.1. For now, remember the practical difference: spawn() gives you live streams; exec() and execFile() gather the output for you.
fork() has a different default. A forked Node process normally inherits the parent's stdio and receives an IPC channel as well. It only gets piped stdio when you pass silent: true or supply a custom stdio setup. In other words, don't assume that every child-process API produces pipes: spawn() does by default, while fork() continues writing to the same terminal as its parent.
import { fork } from 'node:child_process';
const child = fork('./worker.js', { silent: true });
child.stdout.pipe(process.stdout);With silent: true, stdout and stderr are backed by pipes that the parent can read. Keep the default and both child.stdout and child.stderr are null, because there is no new parent-side stream; the child is writing directly to the inherited descriptors.
Custom stdio on fork() comes with one requirement. If you provide an array, it must contain exactly one 'ipc' entry or process.send() and subprocess.send() will not work. The next subchapter digs into the channel itself. Here, the relevant point is simply that the 'ipc' slot must remain in the configuration.
The named stream properties on the parent are aliases for entries in the stdio array:
console.log(child.stdin === child.stdio[0]);
console.log(child.stdout === child.stdio[1]);
console.log(child.stderr === child.stdio[2]);A piped slot contains a stream object. Most other modes leave null there. That does not mean the child lacks the descriptor; it means only that the parent has no stream object representing its end.
Parent Ends And Child Ends
Each pipe joins one child descriptor to one stream object in the parent. Which way the bytes travel depends on the descriptor:
parent writes child.stdin -> child reads process.stdin
child writes process.stdout -> parent reads child.stdout
child writes process.stderr -> parent reads child.stderrThe names make sense once you read them from the child's point of view. child.stdin feeds the child's standard input, so it is writable from the parent. child.stdout carries the child's standard output, so the parent reads from it. child.stderr follows the same rule.
Inside the child, these are the ordinary process streams.
process.stdin.setEncoding('utf8');
for await (const chunk of process.stdin) {
process.stdout.write(chunk.toUpperCase());
}The parent feeds this program through child.stdin:
const child = spawn(process.execPath, ['upper.js']);
child.stdout.pipe(process.stdout);
child.stdin.write('hello\n');
child.stdin.end();Do not leave out that final end(). It waits for queued input to flush, then closes the parent's write side. The child receives EOF on fd 0; its for await loop finishes, and the process is free to exit. If the parent writes data but never calls end(), the child can sit there forever waiting for more.
EOF is just the end-of-input signal for the child's stdin. Code listening in event mode sees 'end'; an async iterator simply finishes.
destroy() is not a substitute with the same meaning. It tears the stream down immediately and may throw away data that is still buffered. Think of destroy() as an abort and end() as "that was the last chunk; please finish normally."
Encoding choices also stay local to the process that makes them. If the parent calls child.stdout.setEncoding('utf8'), only the parent's decoding changes. The child still writes bytes to fd 1. Likewise, setting an encoding on process.stdin inside the child affects decoding there and nowhere else. Between the two processes, the pipe always carries bytes.
Nor does the child somehow gain access to the parent's ChildProcess object. It can read fd 0, write fd 1 and fd 2, and use any extra descriptors the parent deliberately supplied. That is all. A program written in C, Rust, Python, or anything else gets the same view because descriptors belong to the operating system, not to Node.
Output takes the reverse trip: the child writes, and the parent needs to keep reading.
const child = spawn(process.execPath, ['-e', 'process.stderr.write("bad\\n")']);
child.stderr.setEncoding('utf8');
for await (const chunk of child.stderr) {
console.error(chunk);
}Stdout and stderr have independent descriptors and independent buffers. Draining stdout will not help a child whose stderr pipe is full. Read both, or configure whichever one you do not need as inherited or ignored.
The same direction rule applies when you connect two children. The parent takes the readable output of one and pipes it into the writable input of the other.
const upper = spawn(process.execPath, ['upper.js']);
const count = spawn(process.execPath, ['count.js']);
upper.stdout.pipe(count.stdin);
count.stdout.pipe(process.stdout);This may look like a shell pipeline, but it is a Node stream connection between two objects held by the parent. Shell syntax comes later in the chapter. Here, your code is doing the wiring, which means it also owns error handling, closure, and backpressure across both processes.
What Node Builds For A Pipe
By the time spawn() reaches Node's child-process binding, Node has already normalized stdio into an ordered set of instructions, one per slot. The binding also receives the command, argument list, and environment. For every stdio entry, it now knows two things: what the child should receive and whether the parent needs to keep an end of its own.
When an entry is 'pipe', Node asks libuv for a pipe-backed handle. One end becomes fd 0, 1, 2, or whichever extra descriptor the array position represents. The other becomes a stream on the ChildProcess. On macOS and Linux, you may notice that this stream is a Socket instance. That class name is an implementation detail: Node reuses the same stream machinery for several kinds of libuv handles. The reliable part is whether the object is readable or writable.
All of this is ready before the child's user code begins. A Node program can read process.stdin during startup; a native executable can immediately write to fd 1. Both find the configured descriptor in place. There is no JavaScript handshake that sets it up afterward.
Between a write in the child and a callback in the parent sit two buffers. The operating system gives the pipe a finite capacity, and the Node stream adds its own queue and highWaterMark. Bytes may be sitting in the kernel's pipe before the parent emits its first 'data' event. They may also have moved into Node's queue before your handler touches them. Neither space is infinite, and the stream's highWaterMark is not the size of the OS pipe.
Backpressure crosses both layers. As the parent writes to child.stdin, write() eventually returns false when the Node queue passes its threshold. That is your cue to pause until 'drain'. Underneath, libuv is feeding the pipe connected to the child's fd 0. If the child reads slowly, or stops reading altogether, the OS buffer fills, pending writes pile up, and Node retains those chunks until the path opens again or fails.
Child output can run into the same wall in the other direction. Suppose the child produces stdout faster than the parent consumes child.stdout. The pipe fills first; after that, writes stall at the operating-system layer. A native program might block inside write(2). In a Node child, process.stdout.write() begins returning false, and further output can queue inside that process. It has not exited or crashed. It is alive, waiting for room.
This is behind many mysterious "the parent just hangs" reports. The parent waits for an exit, while the child waits for the parent to make space by reading. Neither can move. The remedy is pleasantly unglamorous: consume the output, or choose inherit or ignore so there is no pipe to fill.
Only slots backed by a pipe populate the corresponding parent fields. With stdio: 'inherit', the child uses descriptors the parent already owns, so there is no new stream to store and the fields are null. With stdio: 'ignore', the child still receives valid descriptors, but they point to the null device or its platform equivalent; again, the parent fields are null. The same is true if you pass an existing file descriptor or stream. Of the named modes, only 'pipe' and 'overlapped' create parent-side stream objects.
It is also worth separating process lifetime from stream lifetime. A child may exit while unread bytes remain in child.stdout. Node emits 'exit' when the process ends, then emits 'close' after that child's stdio streams have closed. When output is part of the result, wait for 'close', or explicitly wait for both the process result and the output-consumption path. An 'exit' handler alone can run before the final bytes reach your code.
Extra descriptors use exactly the same machinery. Put a pipe at index 3 and the child receives fd 3; it can write with fs.writeSync(3, data) or hand the descriptor to native code. The parent reads the other end at child.stdio[3]. This is handy for a separate byte channel when stdout and stderr already have jobs and you want to reserve IPC for the structured messages covered next.
Draining Output
When stdout or stderr is a pipe, somebody in the parent must read it. "Draining" is just the name for that job; Node does not quietly do it in the background.
The easiest approach is to pipe each stream to a destination you already have:
const child = spawn('npm', ['test']);
child.stdout.pipe(process.stdout);
child.stderr.pipe(process.stderr);This keeps the pipes moving, but it says nothing about whether the command succeeded. You still need to inspect its exit result:
import { once } from 'node:events';
const [code, signal] = await once(child, 'close');
if (code !== 0) throw new Error(`child failed: ${code ?? signal}`);The choice of 'close' is deliberate. It arrives after the stdio streams finish closing. 'exit' only tells you that the process has ended, and may arrive while output is still on its way through your handlers. If you read output but wait only for 'exit', you have created a race with your own stream code.
The destination is your choice: a terminal, a file, a parser, or something else entirely. The only hard requirement is that something keeps consuming the pipe.
If you already created a pipe and later realize you do not need its contents, calling resume() will read and discard the chunks:
const child = spawn(process.execPath, ['script.js']);
child.stdout.resume();
child.stderr.resume();That works, although stdio: 'ignore' would have expressed the intent more cleanly at spawn time. resume() still creates the pipes and stream objects, and the parent still has to perform the reads.
If you collect output in memory, give the collection a ceiling. exec() and execFile() have maxBuffer; spawn() has no matching option, so you must enforce the limit yourself.
let bytes = 0;
for await (const chunk of child.stdout) {
bytes += chunk.length;
if (bytes > 1_000_000) child.kill();
}Here, reading and counting happen together, and the child is killed after one million bytes. Without a cap, you have merely replaced one failure mode, a full pipe, with another: memory in the parent can grow without bound.
Line-oriented parsers need limits too. Set a maximum line length, a maximum total size, or both. More generally, treat child stdio as an untrusted byte stream whenever anything outside your program can affect the child's behaviour. That includes user input, but it can be as mundane as an external tool changing its output in a new release.
When the output is machine-readable, keep stdout and stderr in different handlers:
child.stdout.on('data', chunk => parseData(chunk));
child.stderr.on('data', chunk => logDiagnostic(chunk));Now protocol data has one path and diagnostics have another. A human may be happy to see the two merged in a terminal. A parser will not be happy when a warning suddenly appears in the middle of its data.
const child = spawn('tool', ['--json'], {
stdio: ['ignore', 'pipe', 'pipe'],
});This is the default spawn() arrangement written explicitly. The parent can parse stdout as JSON and send stderr to its logs. Of course, stdio cannot force a badly behaved tool to put warnings on stderr. It keeps the descriptors separate; the program still decides what to write to each one.
When you truly do not want output, say so in the configuration:
const child = spawn(process.execPath, ['script.js'], {
stdio: ['ignore', 'ignore', 'inherit'],
});In this example, stdin and stdout go to ignored targets while stderr passes through to the parent's stderr. Consequently, child.stdin and child.stdout are null. The child can still write to fd 1; those bytes simply go somewhere that cannot fill up and does not need a reader.
That is safer than opening a pipe and abandoning it. An unread pipe has finite room and will eventually stop the child. An ignored descriptor does not fill.
Do check how the program actually uses its streams before choosing the modes. Data on stdout and progress messages on stderr is a common convention, not a law. Some tools log to stdout; others use both streams unpredictably.
A reasonable rule of thumb is to use ignore for the detached, fire-and-forget work discussed later in the chapter, inherit for a foreground CLI that should feel like part of the current terminal, and pipe when the parent needs the bytes.
Contrast that with this risky version, which waits without attaching any readers:
const child = spawn(process.execPath, ['very-noisy.js']);
const [code] = await once(child, 'close');It is safe only if stdout and stderr are inherited or ignored, or if the child is guaranteed to produce very little output. With spawn()'s default pipes, the parent owns the draining. A sufficiently noisy child will fill one of them and stall before it can exit.
Writing Input And Closing It
Configure fd 0 as 'pipe' and child.stdin becomes a Writable in the parent. Bytes written there arrive at the child's process.stdin.
const child = spawn(process.execPath, ['-e', 'process.stdin.pipe(process.stdout)']);
child.stdout.pipe(process.stdout);
child.stdin.write('alpha\n');
child.stdin.end('omega\n');The end('omega\n') call does two jobs: it sends one last chunk, then closes the stream. The child receives both lines followed by EOF. Many command-line programs hold back their final result until they see EOF. sort is an easy example; it cannot know the correct order until all lines have arrived.
In stream terms, child.stdin.end() is asynchronous. It queues any final bytes and marks the writable as ending; 'finish' arrives later, once Node has handed everything to the underlying system. Most code can simply wait for the child's 'close' event. If you specifically need confirmation that the write side finished, wait for that stream too:
import { finished } from 'node:stream/promises';
child.stdin.end(payload);
await finished(child.stdin);That await may reject when the child closes stdin early. Whether to treat that as failure depends on the agreement between parent and child. Some protocols promise to consume every byte. Other programs intentionally stop as soon as they have enough input, in which case the rejection may be harmless.
Stdin is still a stream, so the usual backpressure rules apply:
import { once } from 'node:events';
if (!child.stdin.write(chunk)) {
await once(child.stdin, 'drain');
}When write() returns false, the chunk has been accepted, but the internal queue has crossed its threshold. Stop there and wait for 'drain'. Ignoring that signal lets memory use climb without bound. It is the same rule from the streams chapters, only this writable happens to lead to another process.
Errors here combine normal stream behaviour with process timing. Perhaps the child exits before the parent has finished writing. Perhaps it closes fd 0 itself, or rejects the input and quits. From the parent's side, these cases commonly surface as EPIPE on child.stdin.
child.stdin.on('error', err => {
if (err.code === 'EPIPE') return;
throw err;
});EPIPE literally means that the read side of the pipe is gone. For child stdin, the usual explanation is that the child closed fd 0 or exited. Context decides whether that is acceptable. A filter such as head is expected to stop early. A worker that promised to consume the whole request has failed if it disappears halfway through.
Once you decide to kill a child, stop sending it input as well. Pending writes often fail during teardown, creating extra errors that tell you nothing new. Kill the process, wait for 'close', and record the outcome.
For per-chunk feedback, use the write callback:
child.stdin.write(chunk, err => {
if (err?.code === 'EPIPE') return;
if (err) throw err;
});For the same failure, this callback runs before the stream's 'error' event. You should still keep an 'error' listener on the stream because not every failure belongs to a particular write callback.
The everyday write-and-finish sequence fits in a small helper:
async function sendInput(child, chunks) {
for (const chunk of chunks) {
if (!child.stdin.write(chunk)) await once(child.stdin, 'drain');
}
child.stdin.end();
}The helper moves through the chunks, pauses whenever the queue asks for breathing room, and calls end() after the last one. It does not make early exits disappear; callers still need an error handler on child.stdin or a finished() wrapper.
If your input is already a stream, stream.pipeline() can coordinate backpressure and errors for you:
import { pipeline } from 'node:stream/promises';
await pipeline(source, child.stdin);This promise resolves when the input path is finished. It does not report the child's exit status, so wait for 'close' and check that result separately.
Inherit, Ignore, And Files
Here is the short version of the three main modes. 'pipe' creates streams for the parent. 'inherit' lets the child use descriptors the parent already has. 'ignore' supplies valid throwaway descriptors and leaves the corresponding parent fields as null.
Used as a string, the selected mode covers fd 0, 1, and 2 together:
spawn('npm', ['test'], { stdio: 'inherit' });
spawn(process.execPath, ['script.js'], { stdio: 'ignore' });Choose inherit when the child should feel like part of the current terminal session. It writes to the same destination as the parent, and if that destination is a TTY, the child can discover that through its own stdio objects. Interactive prompts, colours, spinners, and cursor movement often rely on that TTY check.
Choose ignore for data you genuinely mean to discard. Node still gives the child usable descriptors for 0, 1, and 2, so writes do not break; there is simply nothing for the parent to drain.
These choices are visible on the parent object as well:
const child = spawn('npm', ['test'], { stdio: 'inherit' });
console.log(child.stdin);
console.log(child.stdout);
console.log(child.stderr);After a successful spawn, all three lines print null. The child uses the inherited descriptors directly, leaving the parent with no newly created stream objects.
An array lets you make the decision one slot at a time:
const child = spawn('git', ['status'], {
stdio: ['ignore', 'pipe', 'inherit'],
});Here, stdin is ignored and stdout is captured, while stderr passes straight through to the parent's stderr. Your program can parse Git's normal output without hiding its warnings from the person at the terminal.
An array entry may also refer to one of the parent's descriptors by number:
const child = spawn('git', ['status'], {
stdio: ['ignore', 1, 2],
});The child's stdout now targets the parent's fd 1, and its stderr targets the parent's fd 2. Those two entries behave like inheritance while the array still lets you ignore stdin.
A file is another possible destination:
import { openSync, closeSync } from 'node:fs';
const out = openSync('child.log', 'a');
const child = spawn('npm', ['test'], { stdio: ['ignore', out, out] });
child.on('close', () => closeSync(out));The child uses the numeric descriptor for both stdout and stderr, so the matching parent fields are null. Notice that the descriptor remains open in the parent as well. You opened it, so you are responsible for closing it; that is why the example calls closeSync from the 'close' handler.
You may pass a stream object too, provided it has an underlying descriptor. An fs.createWriteStream() can receive child output this way. As with a raw fd, the child writes directly to that target and child.stdout remains null for the slot.
import { spawn } from 'node:child_process';
import { once } from 'node:events';
import { createWriteStream } from 'node:fs';
const log = createWriteStream('child.log', { flags: 'a' });
await once(log, 'open');
spawn('npm', ['test'], { stdio: ['ignore', log, log] });Why wait for 'open'? A WriteStream acquires its descriptor asynchronously. Before that event, there may be no descriptor for spawn() to use. Waiting makes the ordering explicit instead of depending on a particular stream state or validation path.
These are all forms of descriptor inheritance: the child receives access to something already open in the parent. The 'inherit' keyword selects the matching standard descriptor; a positive integer selects that exact parent fd; and a stream contributes its underlying descriptor or handle where the platform supports it.
The word "access" deserves emphasis. Once the child starts, it can read from or write to whatever the inherited descriptor represents. There is no second permission check. Only hand over descriptors the child actually needs.
The consequences are clearest with files and sockets. A log-file descriptor lets the child write to that file. A socket descriptor places it on the connection. Even inherited stdin may let it read from the same terminal or pipe as the parent. If an unintended descriptor is shared, the mistake is in the parent's stdio configuration.
For a long-running service, it is usually worth spelling out every standard descriptor instead of drifting into a default. A background worker that inherits stdout writes into whatever logging destination the process manager gave its parent. Inherited stdin can keep an unwanted terminal or pipe alive. Ignored stderr may bury the one diagnostic you need during an incident. There is no universal combination; choose according to who needs the result and where the child's diagnostics belong.
Extra Stdio Descriptors
Entries after index 2 create extra child descriptors: index 3 becomes fd 3, index 4 becomes fd 4, and so forth.
import { spawn } from 'node:child_process';
const child = spawn(process.execPath, [
'-e',
'require("node:fs").writeSync(3, "metric=1\\n")',
], { stdio: ['ignore', 'ignore', 'inherit', 'pipe'] });The other end is available to the parent as child.stdio[3]:
child.stdio[3].setEncoding('utf8');
child.stdio[3].on('data', line => {
process.stdout.write(`fd3: ${line}`);
});Stdout and fd 3 now carry separate byte streams. They cannot become interleaved by accident. Some native tools already understand a dedicated status descriptor. A Node child can write with fs.writeSync(3, data), or wrap fd 3 in a stream:
import { createWriteStream } from 'node:fs';
const status = createWriteStream(null, { fd: 3 });
status.write('ready\n');
status.end();This does not open a new file at fd 3. It wraps the descriptor the parent arranged before startup, which is already ready to use.
Remember that an extra stdio pipe is only a byte channel. If messages need boundaries, you must design the framing yourself. Node's IPC channel adds structured messages, handle passing, and lifecycle behaviour; that is the subject of the next subchapter. An fd 3 pipe supplies none of it automatically.
It also follows the same draining rule as every other pipe. Stop reading child.stdio[3] and it can fill just as fd 1 can.
Closure is familiar too. While the child keeps fd 3 open, the parent-side stream stays open, and the child's 'close' event accounts for that pipe. A normal process exit closes its descriptors. Grandchildren add a complication because an inherited handle can outlive the immediate child; we will meet that case later with supervisors and detached work. For now, consider extra descriptors part of the stdio set Node tracks for this child.
The subprocess.stdio Array
The parent records every configured slot, in order, in subprocess.stdio. Expect plenty of null values: only a piped slot produces an object on the parent side.
const child = spawn('git', ['status'], {
stdio: ['ignore', 'pipe', 'inherit'],
});
console.log(child.stdio[0]);
console.log(child.stdio[1] === child.stdout);
console.log(child.stdio[2]);Slot 0 prints null. Slot 1 is the very same object as child.stdout. Slot 2 is null because stderr was inherited.
For the standard three, the named aliases are usually clearer; child.stdout reads better than child.stdio[1]. Extra descriptors have no aliases, so they are reached as child.stdio[n].
Think of this array as the parent's snapshot of the spawn-time configuration. The child may later open files, duplicate descriptors in native code, or create sockets. None of those appear here. subprocess.stdio describes only the entries that were part of the stdio setup.
There is a small failure-case wrinkle: after an unsuccessful spawn, some fields may be undefined rather than null. Generic code should be comfortable with either value.
The array becomes useful when a helper does not know the child's exact configuration. It can walk every slot and attach an error handler wherever a stream exists:
for (const stream of child.stdio) {
if (stream) stream.on('error', onStdioError);
}That loop also catches failures from extra piped descriptors. They are ordinary stream objects and can fail like any others.
A generic discard loop looks similar:
for (const stream of child.stdio) {
if (stream?.readable) stream.resume();
}Use such a broad drain only when every readable stream really is disposable. Usually, each descriptor has a particular purpose, and handling it by name makes that purpose easier to see.
Cross-Platform Details
The modes 'pipe', 'ignore', and 'inherit' are portable across Node's supported platforms. Their foundations differ: Unix uses descriptors and Windows uses handles. The JavaScript API stays consistent.
'overlapped' is the Windows-oriented exception. There it acts like 'pipe', with overlapped I/O enabled on the child's handle. On other platforms Node treats the entry as an ordinary 'pipe'.
const child = spawn(process.execPath, ['worker.js'], {
stdio: ['ignore', 'overlapped', 'overlapped'],
});Most portable code can stay with 'pipe'. If a particular Windows child requires overlapped handles, use 'overlapped' for those slots; elsewhere, the configuration remains pipe-equivalent.
Whether the child sees a TTY also changes its behaviour. With 'inherit', stdout or stderr remains a TTY whenever the parent's corresponding descriptor is one. A pipe, however, is just a stream. Many CLIs inspect isTTY and, when it is false, disable colour and progress displays or alter their buffering. That choice happens inside the child after it looks at its descriptors.
This explains why the same command may act differently when launched from a terminal and from Node. The executable and arguments have not changed; stdio has. Under inherit, a CLI might draw a live progress bar. Under pipe, it may switch to plain lines or buffer output because stdout is no longer a terminal.
Flags and environment variables can sometimes override those choices, but stdio remains part of the program's environment. If you plan to parse the result, prefer an explicit machine-readable mode such as --json over assumptions about terminal detection.
On Windows, console-window visibility is a separate setting. windowsHide controls whether a child that would create a console window receives a hidden one. Users may notice the combined effect of windowsHide and stdio, but the settings do different jobs: one concerns the window, the other the child's handles.
"File descriptor" is Unix vocabulary; Windows deals in handles. Node deliberately keeps the familiar fd-style indexes in its JavaScript API, allowing the same array to describe standard streams on either family of systems.
Text still needs platform awareness. A pipe transports bytes, not an encoding or a line-ending convention. Decoding as UTF-8 is a decision made by the receiving process. If a Windows-native tool emits another encoding, child.stdout still receives raw bytes, and the parent must decode them correctly.
Deadlocks And Close Ordering
Most stdio deadlocks boil down to one of two small loops. In the first, output has nowhere to go:
parent waits for child completion
child waits for stdout or stderr capacity
parent leaves that output unreadIn the second, the child never learns that input is finished:
parent waits for child completion
child waits for EOF on stdin
parent leaves child.stdin openNo individual call in either sequence is invalid. The problem is the missing call: nobody reads a piped stdout or stderr, or nobody calls end() on a piped stdin.
When a child appears stuck, first confirm the configuration it actually received. The default spawn() arrangement creates three streams for the parent to manage. With stdio: 'inherit', by contrast, the parent was never responsible for consuming output, so the cause lies elsewhere.
console.log(child.stdio.map(stream => stream && stream.constructor.name));
console.log(child.stdin?.writableNeedDrain);The first log reveals which slots have parent-side objects. The second tells you whether writes to stdin have crossed the writable threshold. It is only one diagnostic clue, but a true value points toward queued input rather than, for example, a child consuming CPU in a tight loop.
Attach stdout and stderr consumers soon after spawning. A late listener can still receive buffered data, but the OS pipe may already have filled and paused the child in the meantime. Doing expensive setup between spawn() and the first read only makes that vulnerable window larger.
For output-producing children, 'close' gives you the process result after the stdio streams have closed:
import { once } from 'node:events';
const child = spawn(process.execPath, ['-e', 'process.stdout.write("done")']);
let out = '';
child.stdout.on('data', chunk => { out += chunk; });
const [code] = await once(child, 'close');By the time this await settles, the child has ended and Node has closed its associated stdio streams. The out string contains everything collected by the 'data' listener.
'exit' arrives sooner and reports only process status. Bytes may still be queued in the parent's stream machinery. That is perfectly fine for coordination that never examines output. Once output forms part of the result, 'close' is the safer event.
Errors need their own paths as well. Failure to spawn emits 'error' on the child. Writing after the child closes stdin can fail on child.stdin. A pipeline consuming stdout may fail independently of the child's exit code. Keep process status and stream errors as separate pieces of evidence, then combine them according to the operation's contract.
Sometimes it is clearest to wait for process closure and stream completion together:
const closed = once(child, 'close');
const outputDone = finished(child.stdout);
child.stdout.pipe(destination);
const [[code]] = await Promise.all([closed, outputDone]);Both waits are active at once: one observes the process, the other stdout. Real application code would account for stderr and spawn failures too. The important distinction is that process status and stream completion come from different event sources, even though your application treats them as one operation.
Once the policy is decided, a cooperative parent can be remarkably small:
child.stdout.pipe(process.stdout);
child.stderr.pipe(process.stderr);
child.stdin.end();
const [code] = await once(child, 'close');Those four lines cover the three obligations we have been circling throughout the chapter: drain piped output, finish piped input, and wait for the event that includes stdio closure. When a Node child hangs, one of these responsibilities is very often missing.
Application policy still belongs on top. You might impose a deadline or an output cap. Perhaps EPIPE is normal because the child is a filter that deliberately stops early. Perhaps stdout is parsed while stderr goes directly to the terminal. Whatever you decide, the original stdio configuration must expose the streams that decision depends on. You cannot count bytes from an inherited slot because the parent never receives them.
The three-pipe default is wonderfully handy for small commands because it puts every standard stream within reach of the parent. In production, I prefer to write the configuration explicitly even when it matches that default. Output always goes somewhere: to your reader, an inherited terminal, a file, or a discard target. The code should make the destination obvious. Many real-world hangs begin with a child whose output was small enough during development that nobody had to decide. The first large production run makes the decision for them by filling the pipe.