Get E-Book
Child Processes & OS Workloads

spawn(), exec(), execFile(), and fork()

Ishtmeet Singh @ishtms/June 11, 2026/28 min read
#nodejs#child-process#spawn#exec#fork

So what exactly happens when you call spawn() in Node?

You call one function and Node starts another program. And by the time your own code continues, Node has already created a ChildProcess object, asked the operating system to create another process, set up whatever stdio handles you requested, and returned control back to your JavaScript.

That second process is actually a separate process. Different PID, different memory, its own working directory, its own environment, all of it. Your parent process doesn't suddenly share JavaScript objects with it or anything.

Let's start with probably the smallest example we can write without depending on some Unix command that Windows users don't have -

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

const child = spawn(process.execPath, ["-e", "setTimeout(() => {}, 500)"]);

console.log(child.pid);
console.log(child.spawnfile);
console.log(child.spawnargs);

process.execPath is the path to the Node executable that's currently running your program. So instead of assuming grep, bash, cmd.exe, or whatever exists on the machine, we're just starting another Node process. If this program is already running, well, Node obviously exists.

The -e tells that second Node process to execute the JavaScript string after it. In our case it just stays alive for half a second.

And notice the parent doesn't wait there.

spawn() returns and your code continues. child.pid gives you the PID assigned to the child if startup worked, child.spawnfile tells you which executable Node launched, and child.spawnargs shows the arguments Node ended up using.

Everything we're talking about in this chapter comes from node:child_process. Node gives you four async functions you'll use for starting processes - spawn(), exec(), execFile(), and fork(). Then there are blocking versions too - spawnSync(), execSync(), and execFileSync().

Now before getting into those APIs separately, one thing needs to be very clear.

If your Node program starts another program, your Node program is the parent and the new process is the child. You'll also see Node docs say "subprocess". Same child process, just talking about it from the parent's side.

The parent has the ChildProcess JavaScript object. The child itself is an OS process.

And they do not share your JavaScript memory. If the parent has some object -

js
const config = {
  mode: "production",
};

you cannot somehow give the child a reference to config. That object lives inside the parent's JavaScript heap.

The child gets information through things processes can actually receive, i.e. command-line arguments, environment variables, file descriptors, stdin/stdout/stderr, files, sockets, and with fork(), an IPC channel that Node sets up for you.

This process separation is one reason people use child processes in the first place. If some child crashes, the parent doesn't automatically crash with it. You can give the child a different cwd, pass it a reduced environment, set resource limits through the surrounding OS setup, or just run work completely outside your main Node process.

But don't confuse a different cwd with filesystem security, by the way. Setting cwd only changes where relative paths start from. It doesn't stop the child from accessing other paths that its OS user has permission to access.

What the parent decides before startup

Before the child runs even one line of its own code, the parent has already decided quite a lot.

It chooses the executable, arguments, working directory, environment and stdio setup. Node takes all of that, normalizes it internally, passes the native process options down to libuv, and libuv asks the operating system to start the process.

Roughly, the call goes through these parts -

text
your JavaScript
  -> node:child_process
  -> Node's native process code
  -> libuv
  -> operating system
  -> child starts running
  -> events come back to the parent

Take this call -

js
spawn(process.execPath, ["-p", "process.version"]);

process.execPath is the executable. The array after it contains arguments for that executable.

That array has a proper name, the argument vector, or argv.

One useful thing about spawn() is you're not writing one big command string and asking a shell to figure out where one argument ends and another starts. Unless you explicitly request a shell, the values in your args array are already separate arguments.

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

const child = spawn(process.execPath, [
  "-p",
  "process.argv.slice(1)",
]);

child.stdout.pipe(process.stdout);

The child gets its own process.argv.

And that's another thing people sometimes mix up. The child's argv has nothing to do with changing the parent's argv. The parent builds the arguments before starting the child, and the child receives those when it begins running.

If you launch another Node process with a file -

js
spawn(process.execPath, ["worker.js", "job-42"]);

then inside that child, Node sees the executable path, then worker.js, then job-42.

If you use -e, things are slightly different because there isn't a script filename sitting in argv.

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

const child = spawn(
  process.execPath,
  ["-e", "console.log(process.argv)", "x"],
);

child.stdout.pipe(process.stdout);

Run it and you can see exactly what the child believes its command line contains.

The parent's process.argv stays exactly as it was.

cwd belongs to the child

You can also choose which directory the child starts in -

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

const child = spawn(
  process.execPath,
  ["-p", "process.cwd()"],
  {
    cwd: process.cwd(),
  },
);

child.stdout.pipe(process.stdout);

Here we're passing the parent's current directory into the child, so naturally the child prints the same path.

But you could pass another existing directory -

js
const child = spawn(process.execPath, ["-p", "process.cwd()"], {
  cwd: "/some/other/directory",
});

Now that child starts there. Your parent process does not move anywhere.

And if the directory doesn't exist, startup fails. The child never gets far enough to run your JavaScript.

There is also a small command lookup detail here that's easy to miss.

If your command contains a path -

js
spawn("./tool", [], {
  cwd: projectDir,
});

then that relative path is resolved starting from the child's cwd.

But if the command is just a bare executable name -

js
spawn("tool");

then Node has to search for tool using executable lookup rules, normally involving PATH.

Those two cases are different.

./tool says where the file should be relative to a directory. tool says "find a command with this name".

The child's environment

By default, a child gets a copy of the parent's environment.

So if the parent has -

js
console.log(process.env.HOME);

a normal spawned child will usually see the same value. You can override or add values -

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

const child = spawn(
  process.execPath,
  ["-p", "process.env.MODE"],
  {
    env: {
      ...process.env,
      MODE: "child",
    },
  },
);

child.stdout.pipe(process.stdout);

The child prints -

text
child

But the parent's own environment didn't change. Now here's the part people get bitten by.

If you pass env, you're giving Node the environment object the child should receive. So this -

js
env: {
  ...process.env,
  MODE: "child",
}

keeps the parent's existing environment and changes MODE. But this -

js
env: {
  MODE: "child",
}

doesn't mean "only override MODE".

You've given the child a tiny environment containing basically that value instead of copying everything from process.env.

Which means variables the program expected might be gone.

HOME, PATH, credentials, proxy settings, NODE_OPTIONS, stuff injected by your shell or service manager... whatever you didn't include isn't magically copied back in.

For example -

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

const child = spawn(
  process.execPath,
  ["-p", "process.env.PATH"],
  {
    env: {
      MODE: "clean",
    },
  },
);

child.stdout.pipe(process.stdout);

On Unix-like systems you'll normally see -

text
undefined

for process.env.PATH. Why could Node itself still start?

Because process.execPath is already an absolute path. Node doesn't have to search PATH to find it.

Now compare that with -

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

const child = spawn(
  "node",
  ["-p", "process.version"],
  {
    env: {
      PATH: process.env.PATH,
    },
  },
);

child.stdout.pipe(process.stdout);

Here "node" is only a name, so Node has to find the executable.

If it can't find it through the relevant command lookup rules, the process doesn't start.

This explains quite a few annoying "works in my terminal, fails in the service" bugs. Your shell, IDE, test runner, package manager, version manager and service manager can all start your program with different environment values.

So when a child command works from your terminal but not from Node, print the exact command, cwd, and PATH you're passing.

Don't inspect what your terminal has and assume your Node process got the same thing.

And if you already know exactly which executable you want, an absolute path avoids the search completely -

js
spawn(process.execPath, ["-p", "process.version"]);

Much less guessing.

What exactly is a ChildProcess object?

All four async APIs, spawn(), exec(), execFile(), and fork(), return a ChildProcess.

That object is an EventEmitter, and Node puts process information, streams and control methods on it.

One slightly weird part is Node gives you the JavaScript object immediately. The actual OS process might still be starting at that point.

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

const child = spawn(
  process.execPath,
  ["-e", "console.log('ready')"],
);

child.on("spawn", () => {
  console.log("spawned");
});

child.stdout.on("data", chunk => {
  process.stdout.write(chunk);
});

child.on("close", code => {
  console.log({ code });
});

If startup succeeds, Node emits 'spawn'. And 'spawn' comes before data from stdout or stderr.

Later, when the process ends, you can get 'exit'. Then once the configured stdio streams have also finished closing, you get 'close'.

Those last two sound almost identical at first, but they're not.

'exit' means the process has ended.

'close' means the process has ended and its stdio streams are done too.

Why would that be different?

Because the child can exit while some output is still sitting in pipes waiting for the parent to read it.

So if your result depends on having all of stdout and stderr, 'close' is normally the event you care about.

A working short process often goes something along these lines -

text
spawn
stdout/stderr data
exit
close

A process that couldn't start at all is different.

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

const child = spawn("__this_command_does_not_exist__");

child.on("spawn", () => {
  console.log("spawn");
});

child.on("error", err => {
  console.error("error", err.code);
});

child.on("close", (code, signal) => {
  console.log("close", code, signal);
});

You won't get 'spawn', because there was never a successfully started target process.

You'll get 'error'.

That's useful when debugging because "process failed" is too vague. Did Node fail to start anything at all, or did the program start successfully and then return an error status?

Completely different problems.

pid, spawnfile, and spawnargs

Once startup succeeds, child.pid contains the child's process ID.

js
console.log(child.pid);

Don't treat a PID as some permanent identity though. Operating systems reuse them. Once that child has exited, the same numeric PID can later belong to another process.

spawnfile tells you what Node launched -

js
console.log(child.spawnfile);

and spawnargs shows the full argument array Node prepared -

js
console.log(child.spawnargs);

These two are really handy when some helper several functions away assembled the command and you're staring at the child wondering why it's receiving the wrong values.

Print what Node actually launched before spending 30 minutes debugging the program being launched.

I've definitely never done that. Anyway.

What spawnfile contains depends on which API you used. With spawn(), it's related to the command you passed. With fork(), you'll see the Node executable. With exec(), the launched process is the shell.

kill() doesn't mean "the process is definitely dead"

There's also -

js
child.kill();

Despite the name, kill() means Node requested that the child receive a termination signal or platform-specific equivalent.

It doesn't mean "I have confirmed this process no longer exists".

On Unix-like systems you can send supported signals. On Windows, signal handling works differently and Node translates the supported operations into what Windows provides.

And the child can sometimes handle a signal rather than exiting from it.

The property -

js
child.killed

doesn't mean the child has definitely terminated either. It tells you Node successfully sent the signal request.

If you want to know how the process actually ended, wait for 'exit' or 'close', or inspect things such as exitCode and signalCode.

So this -

js
child.kill();

if (child.killed) {
// don't interpret this as "the OS process is definitely gone"
}

is worth remembering.

spawn() is the one to understand first

If you're learning these APIs, understand spawn() first. The others become much easier after that.

With spawn(), you give Node an executable and an argument array. Node starts it and, by default, exposes the child's stdout and stderr as streams.

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

const child = spawn(process.execPath, [
  "-e",
  "console.error('err'); console.log('out')",
]);

child.stdout.on("data", chunk => {
  process.stdout.write(`stdout: ${chunk}`);
});

child.stderr.on("data", chunk => {
  process.stderr.write(`stderr: ${chunk}`);
});

The child has its own stdout and stderr. Node connects those to pipes, then exposes the parent's ends as child.stdout and child.stderr.

So while the child is still running, the parent can already consume whatever it prints.

That's one big reason spawn() works well for programs that run for a while or produce a lot of output.

If you start a compiler, package manager, video encoder, backup command, long-running script, whatever, you probably don't want to hold every single byte in memory until the command exits just so you can finally see what it printed.

With spawn(), data arrives as the child writes it. The main setup you're giving Node is roughly this -

text
executable
arguments
environment
working directory
stdio configuration

Node turns that into native process options and passes them down.

And when we say spawn() is asynchronous, we're talking about your parent JavaScript.

The parent does not block until the child finishes.

The operating system still has to create another process. Memory gets allocated, executable code gets loaded, handles are created, the process gets scheduled and so on. None of that became free because JavaScript got control back quickly.

Start a few child processes and nobody cares.

Start an absurd number of them and yes, your machine is going to notice.

Startup failure vs program failure

This distinction is worth spending some time on because it changes how you handle errors.

Suppose the executable doesn't exist -

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

const child = spawn("__nodebook_missing_command__");

child.on("error", err => {
  console.error(err.code);
});

child.on("close", (code, signal) => {
  console.log({ code, signal });
});

The target program never started. You'll generally see an error such as -

text
ENOENT

No 'spawn' event, because Node couldn't start the requested executable. Now compare that with -

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

const child = spawn(
  process.execPath,
  ["-e", "process.exit(7)"],
);

child.on("spawn", () => {
  console.log("started");
});

child.on("exit", code => {
  console.log({ code });
});

This process started perfectly fine. Then its own program exited with status 7.

From Node's point of view, process creation worked. The program simply reported failure through its exit code.

That's why you shouldn't treat every non-zero exit code as a 'error' event. They represent different stages.

A missing executable, bad working directory, permission failure, unsupported uid/gid or OS resource problem can prevent startup.

But if the process started and later decides something went wrong, you normally get an exit status and maybe stderr output.

stdin can keep a child alive

There's another easy one to miss.

By default, spawn() can create a writable stdin pipe for the child.

If the child program waits for input until EOF, and you never send anything or close stdin, then the child just keeps waiting.

For example -

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

const child = spawn(
  process.execPath,
  ["-e", "process.stdin.resume()"],
);

That child is waiting for stdin.

If the parent never closes it, why would the child exit? As far as the child knows, more input could still arrive.

Close it -

js
child.stdin.end();

Now the child receives EOF and can finish.

This is why some command-line programs appear to "hang" when launched from Node even though you expected them to finish. They're not necessarily stuck. They might just be waiting for input you never sent.

What happens below the JavaScript API

You don't need to know Node's native code to use spawn(), but knowing roughly what happens makes some behavior less confusing.

The JavaScript side first checks and normalizes what you passed. It handles the command, coerces arguments as needed, deals with cwd, env, stdio, shell, user/group IDs, Windows-specific options and so on.

Then Node's native process code passes the process setup to libuv.

libuv exposes process creation through uv_spawn(), and its options contain the executable, argv, environment, current directory, stdio setup, some flags and a callback used later when the process exits.

After that, libuv calls whatever process creation APIs the OS provides.

Unix-like systems and Windows don't create processes in exactly the same way. One especially visible difference is command-line handling.

Node's JavaScript API lets you write -

js
spawn("program", ["one", "two"]);

so you've clearly provided two arguments.

On Windows, the lower-level process API ultimately works with a command-line string, so Node/libuv has to build that form from your argument array. That's why Windows has additional quoting-related options such as windowsVerbatimArguments.

You usually don't need to care about that unless you're dealing with unusual quoting behavior. But it explains why command-line quoting bugs can behave differently across operating systems even though your JavaScript call looks identical.

Once another Node executable starts, it's a fresh Node process. New V8 instance, new heap, new module cache, new event loop, new process object.

Nothing from the parent's JavaScript heap is shared just because the child also happens to be Node.

Later, when the OS reports that the child exited, libuv passes that information back into Node. Node records the exit code or signal and emits the appropriate events.

And the stdio handles have their own lifetime, which is why 'exit' and 'close' don't have to happen at the exact same moment.

So when you write -

js
const child = spawn(...);

there are a few different things existing at once, i.e. the JavaScript ChildProcess object in the parent, the real child process managed by the OS, and whatever pipes or handles were created for communication.

They're related, obviously, but they're not one object.

exec() runs a shell command and buffers the result

Now we can get into exec().

exec() is for running a command string through a shell and getting stdout/stderr back after the command finishes.

js
import { exec } from "node:child_process";

exec(
  'node -p "process.version"',
  (error, stdout, stderr) => {
    if (error) throw error;
    process.stdout.write(stdout);
  },
);

Notice the interface.

We didn't give Node an executable plus an args array. We gave it one command string -

js
'node -p "process.version"'

A shell parses that string.

Which means shell syntax can work here too. Pipes, redirection, environment expansion, &&, command separators and whatever else the selected shell supports.

For example, on an appropriate shell you could write a command containing -

sh
first-command && second-command

and the shell understands what && means. Node itself isn't implementing &&. That's the shell.

Another difference from spawn() is exec() buffers stdout and stderr for you. Your callback doesn't run every time another output chunk arrives. Node collects the output and calls you when the process finishes.

That's very convenient for small commands -

js
exec("some-small-command", (error, stdout, stderr) => {
  // whole output available here
});

But you probably don't want to use that for something that can print an unknown amount of data.

Because all that output has to sit in memory somewhere. exec() has a maxBuffer option for this exact reason.

js
import { exec } from "node:child_process";

exec(
  `node -e "process.stdout.write('x'.repeat(2000))"`,
  {
    maxBuffer: 1024,
  },
  error => {
    console.error(error?.code);
  },
);

We've told Node stdout can only grow so far before it's considered too much.

If the child exceeds the configured limit, Node stops the process and reports an error.

So exec() is really convenient when you're saying, "run this shell command and give me its small result".

It's not the API I'd reach for when starting something that's going to print logs for the next 20 minutes.

The error from exec() can mean different things

There's one slightly annoying part about exec(). Your callback starts with one error value -

js
exec(command, (error, stdout, stderr) => {
});

But that error could represent different failures. Maybe the process exited non-zero. Maybe a timeout expired. Maybe the buffered output crossed maxBuffer. Maybe the child was terminated by a signal.

So in real code you sometimes need to inspect more than just -

js
if (error) {
  console.log("failed");
}

For example -

js
import { exec } from "node:child_process";

exec(
  `node -e "process.exit(9)"`,
  error => {
    console.error(error?.code);
  },
);

Here the command actually ran. The child just returned status 9.

That's very different from the executable failing to start or the parent terminating it because output got too large.

Same callback slot, different cause.

execFile() skips the shell

execFile() sits somewhere between spawn() and exec() in terms of how you normally use it.

You give it an executable and an argument array, so no shell is involved by default -

js
import { execFile } from "node:child_process";

execFile(
  process.execPath,
  ["-p", "process.platform"],
  (error, stdout) => {
    if (error) throw error;
    process.stdout.write(stdout);
  },
);

That looks a lot more like spawn(). Executable -

js
process.execPath

Arguments -

js
["-p", "process.platform"]

No shell needs to split the command string because there isn't one big command string.

But unlike normal spawn() usage, execFile() collects stdout and stderr for you and gives them back in the callback once the child is finished.

So if you want to run one known executable, you don't need shell features, and you know the output is small, execFile() is pretty nice.

For example, maybe you're calling a program that returns one version string or one commit ID.

js
import { execFile } from "node:child_process";

execFile(
  process.execPath,
  ["-p", "JSON.stringify(process.versions)"],
  {
    encoding: "utf8",
  },
  (error, stdout) => {
    if (error) throw error;

    const versions = JSON.parse(stdout);
    console.log(versions.node);
  },
);

The child prints a small JSON result, Node collects it, and then we parse it.

Easy. But the buffering rule still applies.

js
import { execFile } from "node:child_process";

execFile(
  process.execPath,
  [
    "-e",
    "process.stdout.write('x'.repeat(2000))",
  ],
  {
    maxBuffer: 1024,
  },
  error => {
    console.error(error?.code);
  },
);

No shell doesn't mean no output limit. execFile() is still buffering.

If output can become huge, use spawn() and consume the stream.

Windows .bat and .cmd files

There's one Windows-specific thing that's useful to know.

A .bat or .cmd file isn't launched the same way as a normal executable file. It needs the Windows command processor.

So if your "executable" is actually something like -

text
build.cmd

you generally need a shell involved, either through exec() or by explicitly starting the relevant command processor.

This can surprise you if your code works with a normal .exe and then you swap it for a .cmd file and expect execFile() to behave exactly the same way.

It doesn't.

Be careful when adding { shell: true }

Both spawn() and execFile() can involve a shell if you request one.

For example -

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

But once you ask for a shell, you've changed how the command is interpreted.

The whole reason an args array is nice is each argument already exists separately. A shell command goes through another parser, and shell-special characters can suddenly mean something.

So if you're launching a direct executable, just use the executable and args array directly.

Don't turn the shell on because it "makes things work" unless the shell is actually required.

And if any part of the command comes from an untrusted value, shell parsing needs extra attention. We'll deal with command injection properly later.

fork() starts another Node process

Now fork().

The name is a little unfortunate if you already know Unix process programming.

Node's fork() is not the same operation as the POSIX fork() system call.

It doesn't clone the parent's current memory and continue from the same instruction or any of that.

Node's fork() starts another Node process, loads the module you provide, and sets up an IPC channel between parent and child.

Suppose worker-child.js contains -

js
process.send?.({
  pid: process.pid,
  argv: process.argv.slice(2),
});

Then the parent can do -

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

const child = fork(
  new URL("./worker-child.js", import.meta.url),
  ["job-42"],
);

child.on("message", message => {
  console.log(message);
});

child.on("close", code => {
  console.log({ code });
});

The child is another complete Node process.

And because fork() creates an IPC channel, the child gets process.send() and the parent can listen for 'message'.

That's the main extra thing fork() gives you. Could you start another Node process with spawn()? Of course.

js
spawn(process.execPath, ["worker-child.js"]);

But then if you want a proper Node IPC channel, you'd need to configure that yourself.

fork() is already set up for this parent-Node-to-child-Node case.

fork() does not share your heap

This is worth repeating because the API name causes confusion. Say the parent has -

js
const jobs = new Map();

After fork(), the child doesn't get a live shared reference to that Map.

It starts its own V8 instance with its own memory.

If you want the child to know something about a job, you send data -

js
child.send({
  type: "job",
  id: 42,
});

Then the child receives a message. That's communication. It isn't shared object memory.

We'll get much deeper into IPC later, because there are quite a few details around serialization, queues and passing handles.

For now, just remember what fork() is buying you, i.e. another Node process plus Node's built-in IPC connection.

execArgv can get inherited

By default, a forked Node process can inherit Node-specific startup flags from the parent through process.execArgv.

Maybe you launched the parent with an inspector flag, a preload, or some Node runtime option.

That can carry over to the child. Sometimes you want that. Sometimes you absolutely don't. You can replace it -

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

const child = fork(
  new URL("./worker-child.js", import.meta.url),
  [],
  {
    execArgv: [],
  },
);

Now you're starting the child without inheriting those Node flags.

The IPC channel is still set up because that's part of fork() itself.

fork() and stdio

fork() also behaves a bit differently from plain spawn() when it comes to normal stdout and stderr.

By default, the child can inherit the parent's stdio, meaning console.log() in the child shows up directly in the same terminal.

If you want pipes on the parent so you can read child.stdout and child.stderr, use -

js
const child = fork(
  new URL("./worker-child.js", import.meta.url),
  [],
  {
    silent: true,
  },
);

Then -

js
child.stdout.on("data", chunk => {
  process.stdout.write(chunk);
});

The IPC channel still exists.

silent changes the ordinary stdio setup, not the fact that this is a forked Node child with IPC.

And remember every forked child is a full Node process. New V8 instance, new heap, new event loop, separate memory.

So if you start 50 of them, you've started 50 Node processes.

That's very different from running 50 callbacks inside one Node instance.

The synchronous versions

Node also gives you blocking versions of the process APIs -

text
spawnSync()
execSync()
execFileSync()

These do exactly what the name suggests. Your JavaScript waits until the child finishes.

js
import { execFileSync } from "node:child_process";

const version = execFileSync(
  process.execPath,
  ["-p", "process.version"],
  {
    encoding: "utf8",
  },
);

console.log(version.trim());

Nothing after that call runs until the child has finished and execFileSync() returns.

Your timers don't continue running in parallel on the JS thread. Network callbacks don't get a chance to run. Other request handlers don't continue doing JavaScript work.

Your Node thread is blocked inside that call.

For a build script or CLI setup step, that can be completely fine.

Sometimes you actually want -

js
run command
wait
use result
continue

and making the code async would just make the script more annoying to read.

But putting a sync child-process call inside a web server request handler means every other request using that JS thread has to wait too.

Probably not what you wanted. The mappings are straightforward -

text
spawn()    -> spawnSync()
exec()     -> execSync()
execFile() -> execFileSync()

spawnSync() returns a result object -

js
import { spawnSync } from "node:child_process";

const result = spawnSync(
  process.execPath,
  ["-e", "process.exit(3)"],
);

console.log(result.status);
console.log(result.signal);

You'll see status 3 and no termination signal. If you pass an encoding -

js
const result = spawnSync(
  process.execPath,
  ["-p", "process.version"],
  {
    encoding: "utf8",
  },
);

console.log(result.stdout.trim());

then stdout comes back as text instead of a Buffer.

The sync APIs can buffer output too, so the same "don't collect unlimited output in memory" rule still exists here.

Blocking and buffering are separate concerns.

So which function do I use?

I wouldn't memorize some giant decision tree for this. There are really a few questions to ask.

Do you need the output while the child is still running?

Use spawn().

js
const child = spawn(command, args);

child.stdout.on("data", chunk => {
  // use output now
});

Do you want to run a normal executable directly, and the final output is small enough to collect in memory?

execFile() fits that pretty well.

js
execFile(file, args, (error, stdout, stderr) => {
});

Do you actually want shell syntax? Use exec().

js
exec(commandString, (error, stdout, stderr) => {
});

Is the child specifically another Node module and you want Node's IPC channel?

Use fork().

js
const child = fork(modulePath);

Do you intentionally want your current JavaScript thread to wait until the process finishes?

Then one of the sync versions can make sense. That's pretty much the decision.

spawn() is a good default for external processes because it gives you direct argument passing and streaming output.

Then move to execFile() if buffering the entire result is actually what you want.

Use exec() because you need a shell, not just because the function name is shorter.

And use fork() for Node-to-Node process communication.

Debugging child process failures without randomly changing things

When something fails, first find out whether the child ever started.

This little setup is useful while debugging -

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

const child = spawn(
  process.execPath,
  [
    "-e",
    "console.error('bad'); process.exit(2)",
  ],
);

child.on("spawn", () => {
  console.log("spawn");
});

child.stderr.on("data", chunk => {
  process.stderr.write(chunk);
});

child.on("exit", (code, signal) => {
  console.log("exit", code, signal);
});

child.on("close", (code, signal) => {
  console.log("close", code, signal);
});

child.on("error", error => {
  console.error("error", error);
});

In this case the child starts, writes "bad" to stderr, exits with status 2, and eventually the stdio closes.

So you already know process creation wasn't the problem. Now try -

js
const child = spawn("__missing__");

This time 'spawn' never comes. 'error' tells you startup failed. That's a much better clue than just printing -

text
child failed

after everything is over. Also log the actual launch values -

js
console.log({
  file: child.spawnfile,
  args: child.spawnargs,
});

And before creating the child, print whatever cwd and PATH you're passing.

You'd be surprised how often the problem turns out to be one missing argument, a working directory you didn't expect, or an environment that doesn't contain what your terminal had.

Actually, maybe you wouldn't be surprised after working with child processes for a while.

One final comparison

Suppose I have a command that prints progress for several minutes.

I'm using spawn().

I want to run one executable, get one small string back, then continue.

execFile().

I genuinely need shell syntax, maybe pipes or redirection.

exec().

I'm starting another Node module and parent/child need to send messages.

fork().

I'm writing a short setup script and I intentionally don't want anything else to continue until the command finishes.

One of the synchronous APIs.

And whatever API you use, keep asking the same few questions when something looks wrong - did the process actually start, what exact arguments did it receive, what environment and working directory did we give it, how does its output come back, and did we wait for the right event?

Once those are clear, spawn(), exec(), execFile(), and fork() stop looking like four nearly identical Node functions with random names.

They're doing different versions of the same general job, which means asking the operating system to run another process, then deciding how your parent process talks to it and gets the result back.