Get E-Book
Child Processes & OS Workloads

stdio Configuration and Piping

Ishtmeet Singh @ishtms/June 11, 2026/32 min read
#nodejs#child-processes#stdio#streams#os

Before your child process gets to run even one line of its own code, stdin, stdout, and stderr are already there waiting for it.

You've probably seen the numbers before. File descriptor 0 is stdin, 1 is stdout, and 2 is stderr. The stdio option in Node decides what those descriptors are connected to when the new process starts.

Inside a Node child, they're the same old process.stdin, process.stdout, and process.stderr we've already used. But what does the parent get? Well, that depends entirely on how you configured stdio. If you told Node to create pipes, the parent gets stream objects for the other ends of those pipes. If you inherited the descriptors or ignored them, there won't be any parent-side streams to read from.

For the common cases, you can just pass a string -

text
'pipe'    -> ['pipe', 'pipe', 'pipe']
'ignore'  -> ['ignore', 'ignore', 'ignore']
'inherit' -> ['inherit', 'inherit', 'inherit']

But arrays are much more interesting because every position controls one descriptor. Index 0 configures the child's fd 0, index 1 configures fd 1, index 2 configures fd 2, and you can keep going with fd 3, fd 4, etc.

So this -

js
stdio: ['ignore', 'pipe', 'inherit']

means the child doesn't get useful stdin, its stdout comes back to us through a pipe, and its stderr goes wherever the parent's stderr is already going.

If an entry is null or undefined, Node uses the default for that position. For the first three positions with spawn(), that means a pipe. Starting from fd 3, the default is ignored.

One thing which is easy to miss here - fd 3 doesn't suddenly become available just because fd 3 exists as a number. You need something at index 3 in your stdio array if you actually want the child to receive something there.

With normal spawn(), if you don't provide stdio at all, Node creates three pipes.

js
import { spawn } from 'node:child_process';

const child = spawn(process.execPath, [
  '-e',
  'process.stdout.write("hi\\n")',
]);

child.stdout.pipe(process.stdout);

So child.stdin, child.stdout, and child.stderr are the parent ends of those three pipes. The child doesn't know or care that Node created them for us. As far as the child process is concerned, it has fd 0, 1, and 2 and can read or write them normally.

Now you may be thinking, don't exec() and execFile() also give us stdout and stderr?

They do, but they work differently. Those APIs collect the output for you and give it back once the command finishes, through the callback or promise result. spawn() gives you the streams while the child is still running, so you can process output as it arrives.

We'll talk about maxBuffer and the buffering side of exec() later. For now, the useful difference is just this, i.e. spawn() gives you live stdio streams, while exec() and execFile() normally collect the output first.

fork() is a little different again. And yes, because apparently one default would've been too easy.

A forked Node process inherits the parent's stdio by default and also gets an IPC channel. If you want stdin, stdout, and stderr piped back to the parent, use silent: true -

js
import { fork } from 'node:child_process';

const child = fork('./worker.js', { silent: true });

child.stdout.pipe(process.stdout);

With silent: true, Node pipes the standard streams, so child.stdout and child.stderr exist. Without it, the child writes using the parent's inherited descriptors and those properties are null.

You can also give fork() a custom stdio array, but if you do that, the array must contain exactly one 'ipc' entry. Remove it and process.send() in the child, along with subprocess.send() in the parent, can't work because you've removed the IPC channel that fork() normally creates.

We'll get properly into IPC in the next subchapter. Just remember that if you're manually changing fork() stdio, don't accidentally delete 'ipc'.

The named properties are just aliases for entries in the stdio array -

js
console.log(child.stdin === child.stdio[0]);
console.log(child.stdout === child.stdio[1]);
console.log(child.stderr === child.stdio[2]);

If that slot was configured as a pipe, you'll normally have a stream object there. Most other configurations give you null.

And null here does not mean the child somehow doesn't have stdout anymore. It only means the parent does not have a new stream object for that descriptor.

Parent Ends And Child Ends

The naming can be slightly confusing the first time because child.stdin is writable, while child.stdout is readable.

Why? Because those names are from the child's point of view.

text
parent writes child.stdin   -> child reads process.stdin
child writes process.stdout -> parent reads child.stdout
child writes process.stderr -> parent reads child.stderr

So when you write into child.stdin, those bytes arrive at the child's standard input. When the child writes to its stdout, the parent can read those bytes from child.stdout.

Let's make a child which just uppercases whatever comes through stdin -

js
process.stdin.setEncoding('utf8');

for await (const chunk of process.stdin) {
  process.stdout.write(chunk.toUpperCase());
}

And then the parent can feed it -

js
const child = spawn(process.execPath, ['upper.js']);

child.stdout.pipe(process.stdout);

child.stdin.write('hello\n');
child.stdin.end();

That last end() is very important. Without it, what tells the child that input is finished? Nothing.

The parent wrote "hello\n", sure, but the child's for await loop is still reading stdin. It has no idea whether another chunk is coming in one second, ten seconds, or never. Calling child.stdin.end() tells Node there will be no more writes. Once the pending input has gone through, the write side closes and the child receives EOF on fd 0.

Then its loop finishes and, assuming nothing else is keeping the process alive, the child can exit.

Forget end() and you've got a very easy way to create a program which just sits there doing nothing forever while you stare at it wondering what happened.

EOF isn't some Node-specific value being sent through the pipe, by the way. It just means there is no more input coming from that stream. If the child uses 'end' events, it'll get an 'end'. If it's using an async iterator as above, the iterator finishes.

And destroy() is different from end().

end() says we're finished writing and lets queued writes complete normally. destroy() tears the stream down. If you've still got data waiting to be written, destroying the stream can stop that data from getting through.

So for normal "send input and then finish" code, you usually want end().

Encoding is also local to whichever side sets it. Say the parent does this -

js
child.stdout.setEncoding('utf8');

That doesn't somehow tell the child to start writing UTF-8 strings. The child is still writing bytes to fd 1. All you've changed is how the parent-side stream decodes those bytes before giving chunks to your JavaScript code.

Same in the other direction. If the child does -

js
process.stdin.setEncoding('utf8');

then the child is asking its own stdin stream to decode incoming bytes as UTF-8. The actual pipe between both processes still carries bytes.

Also, the child doesn't know anything about the parent's ChildProcess object. There's no child.stdout object magically transferred into the child process. That object exists in the parent.

The child only sees its descriptors. fd 0, 1, 2, plus any extra descriptors you configured.

This is also why the child doesn't have to be Node. It can be C, Rust, Python, Go, whatever. File descriptors are an OS-level thing. Node is just configuring them before starting the program.

Output goes the opposite direction, so now the parent needs to read it -

js
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);
}

And stdout and stderr are separate descriptors. Reading stdout doesn't do anything for stderr. If the child is writing loads of data to stderr and nobody reads it, consuming child.stdout won't save you.

Either read both pipes or configure the stream you don't need as inherit or ignore.

You can connect two child processes as well -

js
const upper = spawn(process.execPath, ['upper.js']);
const count = spawn(process.execPath, ['count.js']);

upper.stdout.pipe(count.stdin);
count.stdout.pipe(process.stdout);

Here upper.stdout is readable in the parent and count.stdin is writable in the parent, so Node streams can connect them directly.

There's no shell involved in this code. Your parent process created both children and connected one stream to another. Because your code created the connection, your code also has to deal with stream errors, closure, and backpressure if something goes wrong.

What Node Builds For A Pipe

So what actually happens when you write this?

js
stdio: ['pipe', 'pipe', 'pipe']

Before starting the child, Node takes that configuration and turns it into instructions for each stdio slot. It already knows the executable, arguments, environment and which descriptors need pipes, inheritance, files, or ignored targets.

For a 'pipe' entry, Node sets up a pipe-backed handle through libuv. The child receives one side at the descriptor number for that array position, and Node keeps the parent side so it can expose it through child.stdio[n].

All of this is set up before the child starts executing its own program code.

So a Node child can immediately read process.stdin when it starts. A C program can immediately call write(1, ...). There isn't some later JavaScript setup step happening after the child boots.

Now this is where buffering starts getting important.

When the child writes some output, those bytes don't necessarily go from process.stdout.write() straight into your parent's 'data' callback right away. There's buffering involved in the OS and buffering in Node's stream implementation too.

The OS pipe has finite capacity. Node streams also maintain their own queues and a highWaterMark. Those are not the same buffer, and highWaterMark doesn't tell you the size of the operating system pipe.

This becomes much more obvious when one side is slower than the other.

Say the parent is writing data into child.stdin -

js
child.stdin.write(chunk);

Eventually write() can return false.

That does not mean the chunk was rejected. It means Node has accepted it, but the writable queue has reached the point where you should stop adding more data for now.

So you wait for 'drain' -

js
if (!child.stdin.write(chunk)) {
  await once(child.stdin, 'drain');
}

What happens if you just ignore the return value and keep writing?

Node keeps queueing data in memory.

If the child is reading slowly, the OS pipe may also be full, so Node can't push those queued bytes through quickly. Your memory use keeps climbing while you're producing input faster than the child can consume it.

Child output has the exact same issue in the other direction.

Say your child keeps writing stdout but the parent never reads child.stdout. The OS can buffer some amount of output. But that space is finite. Once it fills, the child can't just continue writing forever.

A native child might block while writing. A Node child will eventually see process.stdout.write() return false and may build its own queue if it ignores that.

The process hasn't necessarily crashed. It can be completely alive and just unable to make progress because nobody is consuming its output.

This causes a very common child-process hang -

text
parent waits for child to exit
child tries to write more output
stdout pipe is full
parent isn't reading stdout
child cannot finish

And then both processes can sit there forever.

The fix isn't very exciting. Read the output. Or if you don't need it, configure stdout as ignore. Or if it should go to the parent's terminal, use inherit.

Don't create a pipe and then pretend you didn't create a pipe.

Parent-side stream objects only appear when Node actually created the relevant pipe. If you use -

js
stdio: 'inherit'

the child is using descriptors that already belong to the parent. Node didn't create a new stdout pipe, so child.stdout is null.

Same with -

js
stdio: 'ignore'

The child still gets a valid fd 1, but it points at a discard target. Again there is no stdout stream for the parent, so child.stdout is null.

Passing an existing fd or stream works similarly. The child writes to whatever you supplied instead of writing into a new pipe owned by the ChildProcess.

One more thing which people often mix up is child lifetime and stdio lifetime.

The child can exit while some output is still working its way through the parent-side streams. Node has an 'exit' event and a 'close' event, and they're not interchangeable.

'exit' tells you the child process ended.

'close' comes after the process has ended and its stdio streams have closed.

So if you're actually collecting output and that output is part of the result you care about, 'close' is normally the event you want.

Extra descriptors behave the same way as stdout and stderr. If you put 'pipe' at index 3, the child receives fd 3, and the parent gets the other side as -

js
child.stdio[3]

The child can use fd 3 directly -

js
fs.writeSync(3, data);

This can be useful when stdout already contains normal program output, stderr already contains diagnostics, and you want another byte stream for some other data.

Draining Output

If stdout or stderr is configured as a pipe, somebody needs to actually read it.

Node doesn't quietly throw the output away for you.

One easy option is to just pipe it to the parent's streams -

js
const child = spawn('npm', ['test']);

child.stdout.pipe(process.stdout);
child.stderr.pipe(process.stderr);

Now stdout and stderr keep getting consumed as the child runs.

This doesn't tell you whether the command succeeded, though. That's separate. You still need the child result -

js
import { once } from 'node:events';

const [code, signal] = await once(child, 'close');

if (code !== 0) {
  throw new Error(`child failed: ${code ?? signal}`);
}

I'm using 'close' here on purpose because the command's output is part of what we're handling. By the time 'close' fires, Node has seen the process end and the child's stdio streams close.

If you waited only for 'exit', there may still be output not processed by your stream handlers yet.

You don't have to pipe output to a terminal. You might send it to a file, feed it into a parser, calculate something from it, or just consume and discard it.

If you've already created a pipe and later decide you genuinely don't care about its data, resume() will consume it without you writing a 'data' handler -

js
const child = spawn(process.execPath, ['script.js']);

child.stdout.resume();
child.stderr.resume();

That works. Though if you already knew before spawning that you didn't want the output, this would've been clearer -

js
stdio: ['ignore', 'ignore', 'ignore']

resume() still means Node created the pipes and the parent is still reading from them. You're just discarding each chunk after it arrives.

Now suppose you want to collect stdout in memory. Fine, but put some limit on it.

exec() and execFile() already have maxBuffer because those APIs collect output. spawn() doesn't have a built-in equivalent since it gives the stream to you. If you're collecting it yourself, then the size limit is also your job.

For example -

js
let bytes = 0;

for await (const chunk of child.stdout) {
  bytes += chunk.length;

  if (bytes > 1_000_000) {
    child.kill();
  }
}

Without a cap, the child might keep sending output and your parent might keep storing it until memory becomes the next problem.

Same goes for line-based parsers. If you're reading newline-delimited data, don't assume every line will always be 100 bytes because that's what your test command happened to produce. Set whatever limits make sense for the program you're calling.

And if the output can be affected by something outside your process, be a little suspicious of it. A new version of some command-line tool can suddenly start printing much more data than before. You don't need an attacker for this to become a problem.

If stdout contains machine-readable data and stderr contains diagnostics, keep them separate -

js
child.stdout.on('data', chunk => parseData(chunk));
child.stderr.on('data', chunk => logDiagnostic(chunk));

This is especially useful when you're doing something such as -

js
const child = spawn('tool', ['--json'], {
  stdio: ['ignore', 'pipe', 'pipe'],
});

Now stdout can go into your JSON parser and stderr can go into logs.

Of course, Node can't force the child program to behave nicely. If that tool randomly prints a warning to stdout, your parser still gets the warning. stdio keeps the descriptors separate, but the child decides what it writes into each descriptor.

If you know you don't want some stream, configure that before starting the process -

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

Here stdin is ignored, stdout is ignored, and stderr goes to the same place as the parent's stderr.

So -

js
child.stdin  // null
child.stdout // null

The child still has fd 0 and fd 1. They just don't have parent-side streams attached.

This is much safer than creating a stdout pipe and never reading it. The ignored output goes to a discard target and there's no unread pipe sitting there waiting to fill.

Which option should you use?

If the child should interact directly with the same terminal as the parent, inherit usually makes sense. If the parent needs to inspect the bytes, use pipe. If nobody needs the data at all, use ignore.

The actual program can change that decision, of course. Some CLIs print normal logs on stdout. Others put progress output on stderr. Some use both in slightly weird ways because software has apparently agreed never to make this completely consistent.

So check what the program actually writes. And be careful with code like this -

js
const child = spawn(process.execPath, ['very-noisy.js']);

const [code] = await once(child, 'close');

At first glance it looks fine. Start child, wait for child.

But spawn() gave you stdout and stderr pipes by default, and nothing here is consuming either one.

If very-noisy.js produces enough output, it can stop before reaching exit because one of those pipes filled.

If you're waiting for piped output to finish, read the piped output.

Writing Input And Closing It

When fd 0 is configured as 'pipe', the parent gets child.stdin, which is a normal Node Writable.

Anything you write there is sent to the child's standard input -

js
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');

end('omega\n') sends one final chunk and then says there won't be any more data after it.

The child receives -

text
alpha
omega

and then EOF.

A lot of command-line programs need that EOF before they can finish. sort is an easy example. If you're feeding lines into sort, it can't produce its final result while it still thinks another line could arrive.

Calling child.stdin.end() doesn't synchronously mean every byte has already reached the child before the function returns. It's still stream code. Node queues the final chunk if there is one, marks the writable as ending, and finishes the remaining writes.

Most of the time, waiting for the child's 'close' event is enough.

But if your code specifically needs to know whether the input stream itself completed successfully, you can use finished() -

js
import { finished } from 'node:stream/promises';

child.stdin.end(payload);
await finished(child.stdin);

This can reject if the child closes its stdin while you're still writing.

Whether that's an actual application error depends on what child you started.

Some programs intentionally stop reading once they have enough data. That's normal for them. But if your worker promised to consume the full request and it disappears halfway through, that's a different situation.

Backpressure works here exactly the same as any other writable stream -

js
import { once } from 'node:events';

if (!child.stdin.write(chunk)) {
  await once(child.stdin, 'drain');
}

If write() returns false, stop writing for now. The chunk was accepted, but the stream queue is already full enough that Node wants you to wait.

If you ignore that and keep pushing chunks, Node keeps buffering them in memory.

This can get especially bad if the child stops reading stdin entirely because now the OS can't get rid of the pending data either.

Another thing you'll eventually meet is EPIPE.

Maybe the child exits early. Maybe it closes fd 0. Maybe it reads enough input, decides it's done, and stops.

Then the parent tries writing again -

js
child.stdin.on('error', err => {
  if (err.code === 'EPIPE') return;

  throw err;
});

EPIPE means the read side is gone.

For child stdin, that normally means the child isn't reading from that pipe anymore because it closed stdin or exited.

Again, whether that's okay depends on the child.

If you're sending data to something which is expected to stop early, an EPIPE might be completely normal. If you're sending a full job to a worker and the worker vanishes after 20% of it, probably not so normal.

And once you've already decided to kill the child, stop trying to feed more input into it too. Otherwise you'll often get a bunch of write errors during shutdown which don't tell you anything you didn't already know.

You can also use the callback for an individual write -

js
child.stdin.write(chunk, err => {
  if (err?.code === 'EPIPE') return;
  if (err) throw err;
});

That callback tells you about that particular write. You should still listen for 'error' on the stream because not every stream failure has to belong to one specific write callback.

A small helper for multiple chunks can look like this -

js
async function sendInput(child, chunks) {
  for (const chunk of chunks) {
    if (!child.stdin.write(chunk)) {
      await once(child.stdin, 'drain');
    }
  }

  child.stdin.end();
}

Nothing very fancy going on. Write a chunk, stop when the stream says to stop, continue after 'drain', and call end() after the final chunk.

You still need to handle stream errors around it.

And if the input already comes from another stream, pipeline() can do most of the stream coordination for you -

js
import { pipeline } from 'node:stream/promises';

await pipeline(source, child.stdin);

That promise tells you about the input stream path. It does not tell you whether the child exited successfully.

So you still need to wait for the child and check its exit result separately.

Two different things happened - input finished, and child process finished. Your application probably cares about both.

Inherit, Ignore, And Files

We've used all three modes already, but let's put them together properly.

'pipe' tells Node to create a new connection between parent and child and expose the parent side as a stream.

'inherit' tells the child to use what the parent is already using.

'ignore' gives the child a valid descriptor but sends its data to a discard target.

When passed as strings, they apply to stdin, stdout, and stderr together -

js
spawn('npm', ['test'], {
  stdio: 'inherit',
});

spawn(process.execPath, ['script.js'], {
  stdio: 'ignore',
});

inherit is useful for commands which should behave as part of the current terminal session.

Say you're writing a Node CLI and running another CLI from it. If you use stdio: 'inherit', the child can read directly from the same terminal and write directly to it.

This can also change how the child behaves because many CLI programs check whether stdout or stderr is connected to a TTY. They may enable colours, interactive prompts, cursor movement, or progress displays when they detect a terminal.

Give the same command a pipe and it may behave differently because now stdout isn't a TTY.

With inherited stdio -

js
const child = spawn('npm', ['test'], {
  stdio: 'inherit',
});

console.log(child.stdin);
console.log(child.stdout);
console.log(child.stderr);

all three are null.

Which can look weird at first. The child is clearly printing stuff, so where's child.stdout?

There isn't one.

The child is writing using the parent's stdout descriptor directly. No new parent-side pipe was created, so Node has no new stream to expose as child.stdout.

Arrays let us mix modes -

js
const child = spawn('git', ['status'], {
  stdio: ['ignore', 'pipe', 'inherit'],
});

This says Git doesn't need stdin, we want to capture its stdout ourselves, and stderr can go straight to the terminal.

You can also pass numeric file descriptors -

js
const child = spawn('git', ['status'], {
  stdio: ['ignore', 1, 2],
});

Now the child's fd 1 uses the parent's fd 1, and the child's fd 2 uses the parent's fd 2.

Which is pretty much what we want when inheriting stdout and stderr, except the array still lets us configure stdin separately.

Files work too.

js
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);
});

Here both stdout and stderr from the child go into the same open file descriptor.

Because we didn't create pipes for those slots -

js
child.stdout // null
child.stderr // null

And don't forget that the parent opened out. That descriptor is still open in the parent too, so the parent needs to close it when it's finished with it.

You can pass stream objects as well, as long as Node can get an underlying descriptor from them.

For example -

js
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'?

Because an fs.WriteStream opens its file asynchronously. Before 'open', the stream may not have an fd ready for spawn() to pass into the child.

So wait until the descriptor exists, then spawn.

All of these configurations are giving the child access to something which already exists in the parent, i.e. a terminal descriptor, a file descriptor, or a stream backed by some OS resource.

And once the child receives that descriptor, the child can use it.

There isn't another permission prompt later.

If you give a child a descriptor for some writable file, it can write to that file. If you pass a usable socket descriptor on a platform where Node supports that operation, the child can interact with that connection. If it inherits stdin, it may be reading from the same terminal or pipe as the parent.

So don't pass descriptors around just because you can. Give the child the ones it actually needs.

For long-running services, I usually prefer writing the three stdio choices explicitly instead of leaving them to whichever defaults happen to apply.

A background process inheriting stdout might end up writing into your service manager's logs. Maybe that's exactly what you wanted. Maybe it isn't.

A child inheriting stdin may keep some input source open longer than you expected. Ignoring stderr means any useful failure message written there is gone.

There's no one configuration which works for every process. Decide where each descriptor should go.

Extra Stdio Descriptors

The stdio array doesn't end at index 2. Anything after that can create extra descriptors for the child.

Index 3 means fd 3, index 4 means fd 4, etc.

For example -

js
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'],
});

We created a pipe at index 3, so inside the child fd 3 exists and points at that pipe.

The parent gets its side here -

js
child.stdio[3].setEncoding('utf8');

child.stdio[3].on('data', line => {
  process.stdout.write(`fd3: ${line}`);
});

Now stdout and fd 3 are two separate streams.

Maybe stdout contains the normal command result, stderr contains errors and warnings, while fd 3 contains status updates for the parent.

A Node child can write directly to fd 3 -

js
import { writeSync } from 'node:fs';

writeSync(3, 'ready\n');

Or wrap it in a stream -

js
import { createWriteStream } from 'node:fs';

const status = createWriteStream(null, {
  fd: 3,
});

status.write('ready\n');
status.end();

That code isn't opening some file and hoping the operating system assigns number 3. fd 3 was already configured by the parent before the child started. createWriteStream() is wrapping that existing descriptor.

One thing to be very clear about - this is still just a byte stream.

An extra pipe doesn't give you message objects, automatic message framing, request IDs, serialization, or anything else.

If you want to send multiple messages through fd 3, your program has to decide how one message ends and the next begins.

You might use newline-delimited text. Maybe fixed-size messages. Maybe a length prefix. Whatever your protocol needs.

Node's IPC channel does more than this, and we'll get to that next. fd 3 with 'pipe' only gives you another stream of bytes.

And yes, the same reading rule applies here too.

If the child keeps writing to fd 3 and the parent never reads child.stdio[3], that pipe can fill just as stdout can.

Extra fd, same stdio problems.

The subprocess.stdio Array

Every configured stdio slot is represented in subprocess.stdio. Suppose we do -

js
const child = spawn('git', ['status'], {
  stdio: ['ignore', 'pipe', 'inherit'],
});

Then -

js
console.log(child.stdio[0]);
console.log(child.stdio[1] === child.stdout);
console.log(child.stdio[2]);

Slot 0 is null because stdin was ignored. Slot 1 is the exact same stream object as child.stdout. Slot 2 is null because stderr was inherited.

For the first three positions, you'll usually use the named properties because they're easier to read -

js
child.stdin
child.stdout
child.stderr

instead of -

js
child.stdio[0]
child.stdio[1]
child.stdio[2]

But extra descriptors don't get cute property names, so fd 3 stays -

js
child.stdio[3]

child.stdio describes the stdio configuration created when Node spawned the process. It is not some live list of every file descriptor the child later opens.

If the child later opens twenty files and ten sockets, they don't suddenly appear in the parent's child.stdio array.

Those were created by the child itself. The array is about the stdio setup Node created during spawn().

There's also a failure case to know about. If Node could not successfully spawn the child, some stdio properties can be undefined rather than the normal null you'd see for non-piped slots.

So very generic code shouldn't assume it always gets either a stream or exactly null.

One reason the array can be handy is when you're writing some helper which doesn't know ahead of time how many piped descriptors exist -

js
for (const stream of child.stdio) {
  if (stream) {
    stream.on('error', onStdioError);
  }
}

That also catches an error on something such as child.stdio[3]. Same if you really want to discard every readable stream -

js
for (const stream of child.stdio) {
  if (stream?.readable) {
    stream.resume();
  }
}

I wouldn't randomly do this in normal application code though. Usually stdout, stderr, and extra descriptors have different jobs, so handling them separately makes the code much easier to understand.

Cross-Platform Details

Most of the stdio API looks the same whether you're on Linux, macOS, or Windows.

'pipe', 'ignore', and 'inherit' work across Node's supported platforms.

Internally the OS concepts are different. Unix talks about file descriptors, while Windows uses handles. Node still gives you this fd-indexed API in JavaScript because it lets the same stdio array work across platforms.

There's one option you'll mostly care about on Windows -

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

'overlapped' works similarly to 'pipe', but on Windows Node enables overlapped I/O on the child handles.

On non-Windows systems, Node treats 'overlapped' the same as 'pipe'.

For most code you can just stay with 'pipe' unless you know you need the Windows-specific behaviour.

TTY detection is another platform-related thing you'll actually notice in normal use.

If you start a CLI with -

js
stdio: 'inherit'

and the parent's stdout is connected to a terminal, the child gets that terminal connection too.

So something inside the child might see -

js
process.stdout.isTTY === true

But when stdout is configured as a pipe, it is no longer the terminal.

Many command-line programs check this and change how they print output. Colours may disappear. A progress display may turn into normal text lines. Some programs change buffering when they know stdout isn't interactive.

This is why you can run some command manually and get colourful interactive output, then spawn the exact same executable from Node and see different output.

The executable didn't necessarily change. Its stdio did.

If you're writing code which parses another program's output, don't depend on whatever formatting it happens to use for humans. If the program provides something such as --json, use that.

Windows also has windowsHide, which is separate from stdio -

js
spawn(command, args, {
  windowsHide: true,
});

That controls whether a console window is shown for a child which would otherwise create one. It doesn't decide where stdin or stdout go.

Different setting, different job. And remember that pipes carry bytes. They don't carry "UTF-8" as some attached property.

If the child writes UTF-8, your parent needs to decode UTF-8. If some Windows-native command writes a different text encoding, then calling -

js
child.stdout.setEncoding('utf8');

doesn't magically make those original bytes UTF-8. You need to know what the program actually emitted.

Deadlocks And Close Ordering

Most child stdio hangs end up being one of two cases.

First one -

text
parent waits for child
child is writing output
output pipe fills
parent isn't reading it

Second one -

text
parent waits for child
child is reading stdin until EOF
parent never closes child.stdin

Nothing especially weird has happened in either case. In the first case, the parent forgot to consume output.

In the second, the parent forgot to say input was finished.

This is why checking the actual stdio configuration is one of the first things you should do when a child seems stuck.

Remember, default spawn() gives you three pipes -

js
const child = spawn(command, args);

So now the parent has child.stdin, child.stdout, and child.stderr, and depending on what the child does, you may need to handle all three.

With -

js
stdio: 'inherit'

there are no parent-side stdout or stderr pipes to fill, so if the child hangs there, you'll need to look somewhere else.

You can inspect what Node gave you -

js
console.log(
  child.stdio.map(stream => stream && stream.constructor.name)
);

console.log(child.stdin?.writableNeedDrain);

writableNeedDrain can help tell you whether stdin has hit backpressure. It doesn't diagnose every possible child-process problem, obviously, but if it's true while the child isn't reading, that's useful information.

Also attach stdout and stderr readers reasonably soon after spawning.

Node can buffer output for you, but again, all buffering has limits. If you spawn some child which immediately dumps huge amounts of output and your code spends ages doing unrelated work before reading it, you can let the pipe fill before your consumer gets involved.

For output-producing children, 'close' is usually a good place to collect the final process result -

js
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 that await finishes, the process has ended and Node has seen its stdio close.

If you used 'exit' instead -

js
await once(child, 'exit');

the process itself has ended, but its stdio streams might still be open.

That's fine when you only care that the child died or exited.

But if stdout is part of your result, waiting for 'close' usually avoids racing the final output.

Stream errors still need handling separately.

The child itself can emit 'error' if spawning fails. child.stdin can fail while you're writing. The destination of a stdout pipeline can fail. And a child can exit with code 1 even though every stream operation worked perfectly.

Those are different failures.

Sometimes you'll want to wait for both process closure and a particular stream operation -

js
const closed = once(child, 'close');
const outputDone = finished(child.stdout);

child.stdout.pipe(destination);

const [[code]] = await Promise.all([
  closed,
  outputDone,
]);

Now one promise is watching the child process and another is watching stdout.

Real code will probably also deal with stderr and spawn failures, but the point is that process completion and stream completion don't come from the same event source.

For a simple cooperative child, the whole setup can still be pretty small -

js
child.stdout.pipe(process.stdout);
child.stderr.pipe(process.stderr);
child.stdin.end();

const [code] = await once(child, 'close');

Read stdout. Read stderr. Close stdin when you're not sending anything. Then wait for the child and stdio to close.

If one of those pieces is missing, that's often why your child process appears to be "hung".

You might have more rules on top of this, of course. Maybe the child gets ten seconds before you kill it. Maybe you're only willing to accept 5 MB of stdout. Maybe EPIPE is expected because the child intentionally stops reading early. Maybe stdout is JSON and stderr gets copied straight into your logs.

All of that depends on your program.

But the initial stdio configuration decides which of those things the parent can even do.

If stdout is inherited, you can't count its bytes through child.stdout because child.stdout doesn't exist. If stderr is ignored, you can't capture its error message afterward. If stdin wasn't piped, you can't later decide to write into child.stdin.

So decide what you need when you spawn the process.

The default three pipes are very convenient, especially for small scripts, because the parent gets access to all three standard streams. But for long-running application code I prefer writing the configuration explicitly, even if what I write ends up being -

js
stdio: ['pipe', 'pipe', 'pipe']

Now anyone reading the code can see that the parent plans to manage all three.

And if instead I write -

js
stdio: ['ignore', 'pipe', 'inherit']

then we know stdin isn't used, stdout is meant for the parent to read, and stderr is going straight through.

That's much easier to reason about six months later than discovering a production process is stuck because its stdout was piped by default, nobody ever read it, and development simply never produced enough output to fill the pipe.